@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.
- package/build/downloads/proxy.js +17 -3
- package/build/index.js +26 -153
- package/build/server/gitlab-request-context.js +166 -0
- package/build/utils/download-token.js +12 -1
- package/package.json +1 -1
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();
|
|
@@ -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
|
|
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",
|
|
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.
|
|
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": [
|