@zereight/mcp-gitlab 2.3.8-next.148 → 2.3.8-next.150

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.
@@ -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();
@@ -63,12 +70,11 @@ import { loadKeyMaterialFromEnv, looksLikeStatelessSessionId, mintSessionId, ope
63
70
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
64
71
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
65
72
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
66
- import { AsyncLocalStorage } from "node:async_hooks";
67
73
  import fetchCookie from "fetch-cookie";
68
74
  import fs from "node:fs";
69
75
  import { pipeline as streamPipeline } from "node:stream/promises";
70
76
  import os from "node:os";
71
- import { fetch as undiciFetch, File, FormData, } from "undici";
77
+ import { fetch as undiciFetch, File, FormData } from "undici";
72
78
  import path from "node:path";
73
79
  import { CookieJar, parse as parseCookie } from "tough-cookie";
74
80
  import { URL } from "node:url";
@@ -98,6 +104,7 @@ import { MCP_INSTRUCTIONS } from "./server/mcp-instructions.js";
98
104
  import { formatUnknownHelpTopic, getWorkflowGuide, readHelpTopic, } from "./server/mcp-help.js";
99
105
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
100
106
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
107
+ import { authBySession, BASE_HEADERS, bindSessionProjectScope, buildAuthHeaders, configureGitLabRequestContext, getEffectiveAllowedProjectIds, getEffectiveApiUrl, getFetchConfig, SESSION_PROJECT_SCOPE_MISMATCH, sessionAuthStore, updateSessionPublicBaseUrl, usesJobTokenHeader, withPublicBaseUrl, } from "./server/gitlab-request-context.js";
101
108
  import { apiUrlUserinfoWarning, commaSplitHint, configuredApiUrlErrors, encodeGitLabPath, encodeGitLabPathSegment, invalidListEntryError, isCleartextRemoteUrl, normalizeGitLabApiUrl, parseConfiguredGitLabApiUrls, } from "./utils/url.js";
102
109
  import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, resolveDownloadRoot, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
103
110
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
@@ -967,6 +974,15 @@ const clientPool = new GitLabClientPool({
967
974
  caCertPath: GITLAB_CA_CERT_PATH,
968
975
  poolMaxSize: GITLAB_POOL_MAX_SIZE,
969
976
  });
977
+ // GITLAB_API_URL and GITLAB_ALLOWED_PROJECT_IDS are defined further down; the getters
978
+ // read them at call time, after module evaluation.
979
+ configureGitLabRequestContext({
980
+ logger,
981
+ getDefaultApiUrl: () => GITLAB_API_URL,
982
+ getDefaultAllowedProjectIds: () => GITLAB_ALLOWED_PROJECT_IDS,
983
+ getDispatcherForUrl: apiUrl => clientPool.getDispatcherForUrl(apiUrl),
984
+ getOAuthAccessToken: () => OAUTH_ACCESS_TOKEN,
985
+ });
970
986
  // Create cookie jar with clean Netscape file parsing
971
987
  // Resolve cookie path once using os.homedir() for cross-platform support
972
988
  const resolvedCookiePath = GITLAB_AUTH_COOKIE_PATH
@@ -1093,129 +1109,6 @@ async function ensureSessionForRequest() {
1093
1109
  logger.debug("Session warmup request failed, will retry on next request");
1094
1110
  }
1095
1111
  }
1096
- const sessionAuthStore = new AsyncLocalStorage();
1097
- // Session context map for storing auth data by session ID
1098
- // This survives async boundaries where AsyncLocalStorage might not
1099
- const authBySession = {};
1100
- function withPublicBaseUrl(authData, publicBaseUrl, previous) {
1101
- const effectivePublicBaseUrl = publicBaseUrl || previous?.publicBaseUrl;
1102
- return effectivePublicBaseUrl ? { ...authData, publicBaseUrl: effectivePublicBaseUrl } : authData;
1103
- }
1104
- function updateSessionPublicBaseUrl(sessionId, publicBaseUrl) {
1105
- if (sessionId && publicBaseUrl && authBySession[sessionId]) {
1106
- authBySession[sessionId].publicBaseUrl = publicBaseUrl;
1107
- }
1108
- }
1109
- // Base headers without authentication
1110
- const BASE_HEADERS = {
1111
- Accept: "application/json",
1112
- "Content-Type": "application/json",
1113
- };
1114
- /**
1115
- * Build authentication headers dynamically based on context
1116
- * In REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH mode, reads from AsyncLocalStorage session context
1117
- * Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
1118
- */
1119
- function buildAuthHeaders() {
1120
- if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
1121
- const ctx = sessionAuthStore.getStore();
1122
- logger.debug({
1123
- sidPrefix: redactSessionIdForLog(ctx?.sessionId),
1124
- header: ctx?.header,
1125
- apiUrl: ctx?.apiUrl === undefined ? undefined : redactUrlSecretsInText(ctx.apiUrl),
1126
- }, "buildAuthHeaders: session context");
1127
- if (ctx?.token) {
1128
- return {
1129
- [ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
1130
- };
1131
- }
1132
- return {}; // No auth headers if no session context
1133
- }
1134
- // Standard mode: PAT preferred over job token (broader permissions).
1135
- // OAuth token takes priority over PAT when both are set.
1136
- // NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
1137
- // If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins.
1138
- // OAuth tokens always travel as `Authorization: Bearer`, even with GITLAB_IS_OLD: the HTTP
1139
- // client strips `Authorization` on cross-origin redirects but not `Private-Token`.
1140
- if (OAUTH_ACCESS_TOKEN) {
1141
- return { Authorization: `Bearer ${OAUTH_ACCESS_TOKEN}` };
1142
- }
1143
- if (GITLAB_PERSONAL_ACCESS_TOKEN) {
1144
- return IS_OLD
1145
- ? { "Private-Token": String(GITLAB_PERSONAL_ACCESS_TOKEN) }
1146
- : { Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}` };
1147
- }
1148
- // Fall back to CI job token
1149
- if (GITLAB_JOB_TOKEN) {
1150
- return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) };
1151
- }
1152
- return {};
1153
- }
1154
- function usesJobTokenHeader() {
1155
- if (GITLAB_JOB_TOKEN)
1156
- return true;
1157
- if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
1158
- const ctx = sessionAuthStore.getStore();
1159
- return ctx?.header === "JOB-TOKEN";
1160
- }
1161
- return false;
1162
- }
1163
- /**
1164
- * Get the effective GitLab API URL for the current request
1165
- * In REMOTE_AUTHORIZATION mode with ENABLE_DYNAMIC_API_URL, reads from session context
1166
- * Otherwise, uses environment GITLAB_API_URL
1167
- */
1168
- function getEffectiveApiUrl() {
1169
- if (ENABLE_DYNAMIC_API_URL) {
1170
- const ctx = sessionAuthStore.getStore();
1171
- if (ctx?.apiUrl) {
1172
- return ctx.apiUrl;
1173
- }
1174
- logger.warn({
1175
- sidPrefix: redactSessionIdForLog(ctx?.sessionId),
1176
- header: ctx?.header,
1177
- apiUrl: ctx?.apiUrl === undefined ? undefined : redactUrlSecretsInText(ctx.apiUrl),
1178
- }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
1179
- }
1180
- return GITLAB_API_URL;
1181
- }
1182
- /**
1183
- * Get the effective project allowlist for the current request
1184
- * In REMOTE_AUTHORIZATION mode with ENABLE_DYNAMIC_PROJECT_SCOPE, reads the scope
1185
- * narrowed via the X-GitLab-Allowed-Project-Ids header from session context
1186
- * (already validated against GITLAB_ALLOWED_PROJECT_IDS by parseAuthHeaders)
1187
- * Otherwise, uses environment GITLAB_ALLOWED_PROJECT_IDS
1188
- */
1189
- function getEffectiveAllowedProjectIds() {
1190
- if (ENABLE_DYNAMIC_PROJECT_SCOPE) {
1191
- const ctx = sessionAuthStore.getStore();
1192
- if (ctx?.allowedProjectIds && ctx.allowedProjectIds.length > 0) {
1193
- return ctx.allowedProjectIds;
1194
- }
1195
- }
1196
- return GITLAB_ALLOWED_PROJECT_IDS;
1197
- }
1198
- /**
1199
- * Keep a dynamic project scope bound to its session
1200
- * A request that omits X-GitLab-Allowed-Project-Ids keeps the session scope, and a
1201
- * request naming a different scope is refused (null). An unscoped session may be
1202
- * narrowed. Re-initialize to change the scope.
1203
- */
1204
- function bindSessionProjectScope(requested, sessionScope) {
1205
- if (!sessionScope || sessionScope.length === 0) {
1206
- return requested;
1207
- }
1208
- if (requested === undefined) {
1209
- return sessionScope;
1210
- }
1211
- const want = new Set(requested);
1212
- const have = new Set(sessionScope);
1213
- return want.size === have.size && [...want].every(id => have.has(id)) ? sessionScope : null;
1214
- }
1215
- const SESSION_PROJECT_SCOPE_MISMATCH = {
1216
- error: "Project scope mismatch",
1217
- message: "X-GitLab-Allowed-Project-Ids does not match the project scope of this session. Re-initialize to change it.",
1218
- };
1219
1112
  /**
1220
1113
  * Reject tools that cannot be limited to allowed projects when
1221
1114
  * ENABLE_STRICT_PROJECT_SCOPE is set and an allowlist is in effect
@@ -1241,24 +1134,6 @@ function filterProjectsByAllowlist(projects) {
1241
1134
  return projects.filter(project => allowedProjectIds.includes(String(project.id)) ||
1242
1135
  allowedProjectIds.includes(project.path_with_namespace));
1243
1136
  }
1244
- /**
1245
- * Get fetch configuration with proper client from pool
1246
- * Uses connection pooling when dynamic API URLs are enabled
1247
- */
1248
- /**
1249
- * Constructs the fetch configuration dynamically.
1250
- * This function is called immediately before each `fetch` to ensure it runs
1251
- * within the correct AsyncLocalStorage context, capturing the necessary auth
1252
- * and API URL information for the current request.
1253
- */
1254
- const getFetchConfig = () => {
1255
- const effectiveApiUrl = getEffectiveApiUrl();
1256
- const dispatcher = clientPool.getDispatcherForUrl(effectiveApiUrl);
1257
- return {
1258
- headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
1259
- dispatcher,
1260
- };
1261
- };
1262
1137
  /**
1263
1138
  * Options for the download sinks that stream a file through `fetchWithValidatedRedirects`.
1264
1139
  *
@@ -10227,7 +10102,7 @@ async function handleToolCall(params) {
10227
10102
  if (local_path) {
10228
10103
  throw new Error("local_path cannot be used in remote mode — use the returned download_url instead");
10229
10104
  }
10230
- const downloadUrl = buildDownloadUrl("job-artifacts", { project_id, job_id });
10105
+ const downloadUrl = buildDownloadUrl("job-artifacts", project_id, { job_id });
10231
10106
  return {
10232
10107
  content: [
10233
10108
  {
@@ -10746,8 +10621,7 @@ async function handleToolCall(params) {
10746
10621
  // In remote mode for non-image files, return proxy URL
10747
10622
  const mimeType = getImageMimeType(args.filename);
10748
10623
  if (IS_REMOTE && !mimeType) {
10749
- const downloadUrl = buildDownloadUrl("attachment", {
10750
- project_id: args.project_id,
10624
+ const downloadUrl = buildDownloadUrl("attachment", args.project_id, {
10751
10625
  secret: args.secret,
10752
10626
  filename: args.filename,
10753
10627
  });
@@ -10885,8 +10759,7 @@ async function handleToolCall(params) {
10885
10759
  case "download_release_asset": {
10886
10760
  const args = DownloadReleaseAssetSchema.parse(params.arguments);
10887
10761
  if (IS_REMOTE) {
10888
- const downloadUrl = buildDownloadUrl("release-asset", {
10889
- project_id: args.project_id,
10762
+ const downloadUrl = buildDownloadUrl("release-asset", args.project_id, {
10890
10763
  tag_name: args.tag_name,
10891
10764
  direct_asset_path: args.direct_asset_path,
10892
10765
  });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Per-request GitLab context shared by the tool handlers: the session auth store,
3
+ * the auth headers, the effective API URL and project allowlist, and the fetch
4
+ * config built from them.
5
+ *
6
+ * index.ts owns the values this module reads (the default API URL, the allowlist,
7
+ * the client pool, the OAuth token, and the logger) and hands them over once with
8
+ * configureGitLabRequestContext(). The getters are read at call time, as the free
9
+ * variables they replace were.
10
+ */
11
+ import { AsyncLocalStorage } from "node:async_hooks";
12
+ import { ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_PERSONAL_ACCESS_TOKEN, IS_OLD, REMOTE_AUTHORIZATION, } from "../config.js";
13
+ import { redactUrlSecretsInText } from "../utils/redact-url.js";
14
+ import { redactSessionIdForLog } from "./request-helpers.js";
15
+ let deps;
16
+ export function configureGitLabRequestContext(next) {
17
+ deps = next;
18
+ }
19
+ function requireDeps() {
20
+ if (!deps) {
21
+ throw new Error("GitLab request context used before configureGitLabRequestContext()");
22
+ }
23
+ return deps;
24
+ }
25
+ export const sessionAuthStore = new AsyncLocalStorage();
26
+ // Session context map for storing auth data by session ID
27
+ // This survives async boundaries where AsyncLocalStorage might not
28
+ export const authBySession = {};
29
+ export function withPublicBaseUrl(authData, publicBaseUrl, previous) {
30
+ const effectivePublicBaseUrl = publicBaseUrl || previous?.publicBaseUrl;
31
+ return effectivePublicBaseUrl ? { ...authData, publicBaseUrl: effectivePublicBaseUrl } : authData;
32
+ }
33
+ export function updateSessionPublicBaseUrl(sessionId, publicBaseUrl) {
34
+ if (sessionId && publicBaseUrl && authBySession[sessionId]) {
35
+ authBySession[sessionId].publicBaseUrl = publicBaseUrl;
36
+ }
37
+ }
38
+ // Base headers without authentication
39
+ export const BASE_HEADERS = {
40
+ Accept: "application/json",
41
+ "Content-Type": "application/json",
42
+ };
43
+ /**
44
+ * Build authentication headers dynamically based on context
45
+ * In REMOTE_AUTHORIZATION or GITLAB_MCP_OAUTH mode, reads from AsyncLocalStorage session context
46
+ * Otherwise, uses environment token (OAuth token is refreshed lazily before each tool call)
47
+ */
48
+ export function buildAuthHeaders() {
49
+ const { logger, getOAuthAccessToken } = requireDeps();
50
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
51
+ const ctx = sessionAuthStore.getStore();
52
+ logger.debug({
53
+ sidPrefix: redactSessionIdForLog(ctx?.sessionId),
54
+ header: ctx?.header,
55
+ apiUrl: ctx?.apiUrl === undefined ? undefined : redactUrlSecretsInText(ctx.apiUrl),
56
+ }, "buildAuthHeaders: session context");
57
+ if (ctx?.token) {
58
+ return {
59
+ [ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
60
+ };
61
+ }
62
+ return {}; // No auth headers if no session context
63
+ }
64
+ // Standard mode: PAT preferred over job token (broader permissions).
65
+ // OAuth token takes priority over PAT when both are set.
66
+ // NOTE: Changed in PR #400 — previously GITLAB_JOB_TOKEN had highest priority.
67
+ // If both GITLAB_PERSONAL_ACCESS_TOKEN and GITLAB_JOB_TOKEN are set, PAT wins.
68
+ // OAuth tokens always travel as `Authorization: Bearer`, even with GITLAB_IS_OLD: the HTTP
69
+ // client strips `Authorization` on cross-origin redirects but not `Private-Token`.
70
+ const oauthAccessToken = getOAuthAccessToken();
71
+ if (oauthAccessToken) {
72
+ return { Authorization: `Bearer ${oauthAccessToken}` };
73
+ }
74
+ if (GITLAB_PERSONAL_ACCESS_TOKEN) {
75
+ return IS_OLD
76
+ ? { "Private-Token": String(GITLAB_PERSONAL_ACCESS_TOKEN) }
77
+ : { Authorization: `Bearer ${GITLAB_PERSONAL_ACCESS_TOKEN}` };
78
+ }
79
+ // Fall back to CI job token
80
+ if (GITLAB_JOB_TOKEN) {
81
+ return { "JOB-TOKEN": String(GITLAB_JOB_TOKEN) };
82
+ }
83
+ return {};
84
+ }
85
+ export function usesJobTokenHeader() {
86
+ if (GITLAB_JOB_TOKEN)
87
+ return true;
88
+ if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
89
+ const ctx = sessionAuthStore.getStore();
90
+ return ctx?.header === "JOB-TOKEN";
91
+ }
92
+ return false;
93
+ }
94
+ /**
95
+ * Get the effective GitLab API URL for the current request
96
+ * In REMOTE_AUTHORIZATION mode with ENABLE_DYNAMIC_API_URL, reads from session context
97
+ * Otherwise, uses environment GITLAB_API_URL
98
+ */
99
+ export function getEffectiveApiUrl() {
100
+ const { logger, getDefaultApiUrl } = requireDeps();
101
+ if (ENABLE_DYNAMIC_API_URL) {
102
+ const ctx = sessionAuthStore.getStore();
103
+ if (ctx?.apiUrl) {
104
+ return ctx.apiUrl;
105
+ }
106
+ logger.warn({
107
+ sidPrefix: redactSessionIdForLog(ctx?.sessionId),
108
+ header: ctx?.header,
109
+ apiUrl: ctx?.apiUrl === undefined ? undefined : redactUrlSecretsInText(ctx.apiUrl),
110
+ }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
111
+ }
112
+ return getDefaultApiUrl();
113
+ }
114
+ /**
115
+ * Get the effective project allowlist for the current request
116
+ * In REMOTE_AUTHORIZATION mode with ENABLE_DYNAMIC_PROJECT_SCOPE, reads the scope
117
+ * narrowed via the X-GitLab-Allowed-Project-Ids header from session context
118
+ * (already validated against GITLAB_ALLOWED_PROJECT_IDS by parseAuthHeaders)
119
+ * Otherwise, uses environment GITLAB_ALLOWED_PROJECT_IDS
120
+ */
121
+ export function getEffectiveAllowedProjectIds() {
122
+ const { getDefaultAllowedProjectIds } = requireDeps();
123
+ if (ENABLE_DYNAMIC_PROJECT_SCOPE) {
124
+ const ctx = sessionAuthStore.getStore();
125
+ if (ctx?.allowedProjectIds && ctx.allowedProjectIds.length > 0) {
126
+ return ctx.allowedProjectIds;
127
+ }
128
+ }
129
+ return getDefaultAllowedProjectIds();
130
+ }
131
+ /**
132
+ * Keep a dynamic project scope bound to its session
133
+ * A request that omits X-GitLab-Allowed-Project-Ids keeps the session scope, and a
134
+ * request naming a different scope is refused (null). An unscoped session may be
135
+ * narrowed. Re-initialize to change the scope.
136
+ */
137
+ export function bindSessionProjectScope(requested, sessionScope) {
138
+ if (!sessionScope || sessionScope.length === 0) {
139
+ return requested;
140
+ }
141
+ if (requested === undefined) {
142
+ return sessionScope;
143
+ }
144
+ const want = new Set(requested);
145
+ const have = new Set(sessionScope);
146
+ return want.size === have.size && [...want].every(id => have.has(id)) ? sessionScope : null;
147
+ }
148
+ export const SESSION_PROJECT_SCOPE_MISMATCH = {
149
+ error: "Project scope mismatch",
150
+ message: "X-GitLab-Allowed-Project-Ids does not match the project scope of this session. Re-initialize to change it.",
151
+ };
152
+ /**
153
+ * Constructs the fetch configuration dynamically.
154
+ * This function is called immediately before each `fetch` to ensure it runs
155
+ * within the correct AsyncLocalStorage context, capturing the necessary auth
156
+ * and API URL information for the current request.
157
+ * Uses connection pooling when dynamic API URLs are enabled.
158
+ */
159
+ export const getFetchConfig = () => {
160
+ const effectiveApiUrl = getEffectiveApiUrl();
161
+ const dispatcher = requireDeps().getDispatcherForUrl(effectiveApiUrl);
162
+ return {
163
+ headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
164
+ dispatcher,
165
+ };
166
+ };
@@ -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.148",
3
+ "version": "2.3.8-next.150",
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": [