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

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/index.js CHANGED
@@ -63,12 +63,11 @@ import { loadKeyMaterialFromEnv, looksLikeStatelessSessionId, mintSessionId, ope
63
63
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
64
64
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
65
65
  import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
66
- import { AsyncLocalStorage } from "node:async_hooks";
67
66
  import fetchCookie from "fetch-cookie";
68
67
  import fs from "node:fs";
69
68
  import { pipeline as streamPipeline } from "node:stream/promises";
70
69
  import os from "node:os";
71
- import { fetch as undiciFetch, File, FormData, } from "undici";
70
+ import { fetch as undiciFetch, File, FormData } from "undici";
72
71
  import path from "node:path";
73
72
  import { CookieJar, parse as parseCookie } from "tough-cookie";
74
73
  import { URL } from "node:url";
@@ -98,6 +97,7 @@ import { MCP_INSTRUCTIONS } from "./server/mcp-instructions.js";
98
97
  import { formatUnknownHelpTopic, getWorkflowGuide, readHelpTopic, } from "./server/mcp-help.js";
99
98
  import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readAcceptHeader, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
100
99
  export { readMcpSessionIdHeader } from "./server/request-helpers.js";
100
+ 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
101
  import { apiUrlUserinfoWarning, commaSplitHint, configuredApiUrlErrors, encodeGitLabPath, encodeGitLabPathSegment, invalidListEntryError, isCleartextRemoteUrl, normalizeGitLabApiUrl, parseConfiguredGitLabApiUrls, } from "./utils/url.js";
102
102
  import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, resolveDownloadRoot, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
103
103
  import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
@@ -967,6 +967,15 @@ const clientPool = new GitLabClientPool({
967
967
  caCertPath: GITLAB_CA_CERT_PATH,
968
968
  poolMaxSize: GITLAB_POOL_MAX_SIZE,
969
969
  });
970
+ // GITLAB_API_URL and GITLAB_ALLOWED_PROJECT_IDS are defined further down; the getters
971
+ // read them at call time, after module evaluation.
972
+ configureGitLabRequestContext({
973
+ logger,
974
+ getDefaultApiUrl: () => GITLAB_API_URL,
975
+ getDefaultAllowedProjectIds: () => GITLAB_ALLOWED_PROJECT_IDS,
976
+ getDispatcherForUrl: apiUrl => clientPool.getDispatcherForUrl(apiUrl),
977
+ getOAuthAccessToken: () => OAUTH_ACCESS_TOKEN,
978
+ });
970
979
  // Create cookie jar with clean Netscape file parsing
971
980
  // Resolve cookie path once using os.homedir() for cross-platform support
972
981
  const resolvedCookiePath = GITLAB_AUTH_COOKIE_PATH
@@ -1093,129 +1102,6 @@ async function ensureSessionForRequest() {
1093
1102
  logger.debug("Session warmup request failed, will retry on next request");
1094
1103
  }
1095
1104
  }
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
1105
  /**
1220
1106
  * Reject tools that cannot be limited to allowed projects when
1221
1107
  * ENABLE_STRICT_PROJECT_SCOPE is set and an allowlist is in effect
@@ -1241,24 +1127,6 @@ function filterProjectsByAllowlist(projects) {
1241
1127
  return projects.filter(project => allowedProjectIds.includes(String(project.id)) ||
1242
1128
  allowedProjectIds.includes(project.path_with_namespace));
1243
1129
  }
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
1130
  /**
1263
1131
  * Options for the download sinks that stream a file through `fetchWithValidatedRedirects`.
1264
1132
  *
@@ -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
+ };
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.149",
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": [