@zereight/mcp-gitlab 2.1.57 → 2.1.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/build/config.js +2 -0
- package/build/index.js +152 -35
- package/build/schemas.js +1 -0
- package/build/stateless/session-id.js +6 -1
- package/build/test/stateless/session-id.test.js +26 -0
- package/build/test/test-dynamic-project-scope.js +362 -0
- package/package.json +1 -1
package/README.ko.md
CHANGED
|
@@ -98,7 +98,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
98
98
|
|
|
99
99
|
예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
|
|
100
100
|
|
|
101
|
-
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.
|
|
101
|
+
전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.57`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
|
|
102
102
|
|
|
103
103
|
#### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
|
|
104
104
|
|
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
103
103
|
|
|
104
104
|
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`.
|
|
105
105
|
|
|
106
|
-
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.
|
|
106
|
+
No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.57`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
|
|
107
107
|
|
|
108
108
|
#### Using CLI Arguments (for clients with env var issues)
|
|
109
109
|
|
package/README.zh-CN.md
CHANGED
|
@@ -98,7 +98,7 @@ npm install -g @zereight/mcp-gitlab
|
|
|
98
98
|
|
|
99
99
|
示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
|
|
100
100
|
|
|
101
|
-
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.
|
|
101
|
+
如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.57`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
|
|
102
102
|
|
|
103
103
|
#### 使用 CLI 参数(适用于环境变量有问题的客户端)
|
|
104
104
|
|
package/build/config.js
CHANGED
|
@@ -85,6 +85,8 @@ export const GITLAB_OAUTH_ALLOWED_GROUPS = (() => {
|
|
|
85
85
|
return groups.length > 0 ? groups : undefined;
|
|
86
86
|
})();
|
|
87
87
|
export const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
88
|
+
export const ENABLE_DYNAMIC_PROJECT_SCOPE = getConfig("enable-dynamic-project-scope", "ENABLE_DYNAMIC_PROJECT_SCOPE") === "true";
|
|
89
|
+
export const ENABLE_STRICT_PROJECT_SCOPE = getConfig("enable-strict-project-scope", "ENABLE_STRICT_PROJECT_SCOPE") === "true";
|
|
88
90
|
// ---------------------------------------------------------------------------
|
|
89
91
|
// Stateless mode (multi-pod safe OAuth / session encoding)
|
|
90
92
|
// ---------------------------------------------------------------------------
|
package/build/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
|
|
2
|
+
import { getConfig, ENABLE_DYNAMIC_API_URL, ENABLE_DYNAMIC_PROJECT_SCOPE, ENABLE_STRICT_PROJECT_SCOPE, GITLAB_AUTH_COOKIE_PATH, GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_DISABLE_VERSION_CHECK, GITLAB_READ_ONLY_MODE, GITLAB_PERMISSION_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
|
|
3
3
|
/** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
|
|
4
4
|
const IS_REMOTE = SSE || STREAMABLE_HTTP;
|
|
5
5
|
const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STREAMABLE_HTTP_AUTH_TOKEN");
|
|
@@ -430,6 +430,7 @@ function createServer() {
|
|
|
430
430
|
lastUsed: authData.lastUsed,
|
|
431
431
|
apiUrl: authData.apiUrl,
|
|
432
432
|
publicBaseUrl: authData.publicBaseUrl,
|
|
433
|
+
allowedProjectIds: authData.allowedProjectIds,
|
|
433
434
|
};
|
|
434
435
|
// Run the handler within the retrieved context
|
|
435
436
|
const result = await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
|
|
@@ -707,6 +708,10 @@ function validateConfiguration() {
|
|
|
707
708
|
if (enableDynamicApiUrl && !remoteAuth) {
|
|
708
709
|
errors.push("ENABLE_DYNAMIC_API_URL=true requires REMOTE_AUTHORIZATION=true");
|
|
709
710
|
}
|
|
711
|
+
const enableDynamicProjectScope = getConfig("enable-dynamic-project-scope", "ENABLE_DYNAMIC_PROJECT_SCOPE") === "true";
|
|
712
|
+
if (enableDynamicProjectScope && !remoteAuth) {
|
|
713
|
+
errors.push("ENABLE_DYNAMIC_PROJECT_SCOPE=true requires REMOTE_AUTHORIZATION=true");
|
|
714
|
+
}
|
|
710
715
|
if (errors.length > 0) {
|
|
711
716
|
logger.error("Configuration validation failed:");
|
|
712
717
|
errors.forEach(err => logger.error(` - ${err}`));
|
|
@@ -967,7 +972,8 @@ async function ensureSessionForRequest() {
|
|
|
967
972
|
redirect: "follow",
|
|
968
973
|
});
|
|
969
974
|
// 401 means auth failed but the request completed - cookies were still exchanged
|
|
970
|
-
|
|
975
|
+
// 403 means insufficient scope (e.g. missing User: Read) but auth is valid
|
|
976
|
+
initialSessionRequestMade = response.ok || response.status === 401 || response.status === 403;
|
|
971
977
|
}
|
|
972
978
|
catch {
|
|
973
979
|
logger.debug("Session warmup request failed, will retry on next request");
|
|
@@ -1048,6 +1054,47 @@ function getEffectiveApiUrl() {
|
|
|
1048
1054
|
}
|
|
1049
1055
|
return GITLAB_API_URL;
|
|
1050
1056
|
}
|
|
1057
|
+
/**
|
|
1058
|
+
* Get the effective project allowlist for the current request
|
|
1059
|
+
* In REMOTE_AUTHORIZATION mode with ENABLE_DYNAMIC_PROJECT_SCOPE, reads the scope
|
|
1060
|
+
* narrowed via the X-GitLab-Allowed-Project-Ids header from session context
|
|
1061
|
+
* (already validated against GITLAB_ALLOWED_PROJECT_IDS by parseAuthHeaders)
|
|
1062
|
+
* Otherwise, uses environment GITLAB_ALLOWED_PROJECT_IDS
|
|
1063
|
+
*/
|
|
1064
|
+
function getEffectiveAllowedProjectIds() {
|
|
1065
|
+
if (ENABLE_DYNAMIC_PROJECT_SCOPE) {
|
|
1066
|
+
const ctx = sessionAuthStore.getStore();
|
|
1067
|
+
if (ctx?.allowedProjectIds && ctx.allowedProjectIds.length > 0) {
|
|
1068
|
+
return ctx.allowedProjectIds;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return GITLAB_ALLOWED_PROJECT_IDS;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Reject tools that cannot be limited to allowed projects when
|
|
1075
|
+
* ENABLE_STRICT_PROJECT_SCOPE is set and an allowlist is in effect
|
|
1076
|
+
*/
|
|
1077
|
+
function rejectIfStrictProjectScope(toolName) {
|
|
1078
|
+
if (ENABLE_STRICT_PROJECT_SCOPE) {
|
|
1079
|
+
rejectIfProjectScopedDeployment(toolName);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Filter a project listing down to the effective allowlist
|
|
1084
|
+
* Applies only when ENABLE_STRICT_PROJECT_SCOPE is set and an allowlist is in effect
|
|
1085
|
+
* Allowlist entries may be numeric IDs or full namespace paths
|
|
1086
|
+
*/
|
|
1087
|
+
function filterProjectsByAllowlist(projects) {
|
|
1088
|
+
if (!ENABLE_STRICT_PROJECT_SCOPE) {
|
|
1089
|
+
return projects;
|
|
1090
|
+
}
|
|
1091
|
+
const allowedProjectIds = getEffectiveAllowedProjectIds();
|
|
1092
|
+
if (allowedProjectIds.length === 0) {
|
|
1093
|
+
return projects;
|
|
1094
|
+
}
|
|
1095
|
+
return projects.filter(project => allowedProjectIds.includes(String(project.id)) ||
|
|
1096
|
+
allowedProjectIds.includes(project.path_with_namespace));
|
|
1097
|
+
}
|
|
1051
1098
|
/**
|
|
1052
1099
|
* Get fetch configuration with proper client from pool
|
|
1053
1100
|
* Uses connection pooling when dynamic API URLs are enabled
|
|
@@ -1217,20 +1264,21 @@ async function handleGitLabError(response) {
|
|
|
1217
1264
|
* @throws {Error} If GITLAB_ALLOWED_PROJECT_IDS is set and the requested project is not in the whitelist
|
|
1218
1265
|
*/
|
|
1219
1266
|
function getEffectiveProjectId(projectId) {
|
|
1220
|
-
|
|
1267
|
+
const allowedProjectIds = getEffectiveAllowedProjectIds();
|
|
1268
|
+
if (allowedProjectIds.length > 0) {
|
|
1221
1269
|
// If there's only one allowed project, use it as default
|
|
1222
|
-
if (
|
|
1223
|
-
return
|
|
1270
|
+
if (allowedProjectIds.length === 1 && !projectId) {
|
|
1271
|
+
return allowedProjectIds[0];
|
|
1224
1272
|
}
|
|
1225
1273
|
// If a project ID is provided, check if it's in the whitelist
|
|
1226
|
-
if (projectId && !
|
|
1227
|
-
throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${
|
|
1274
|
+
if (projectId && !allowedProjectIds.includes(projectId)) {
|
|
1275
|
+
throw new Error(`Access denied: Project ${projectId} is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
|
|
1228
1276
|
}
|
|
1229
1277
|
// If no project ID provided but we have multiple allowed projects, require an explicit choice
|
|
1230
|
-
if (!projectId &&
|
|
1231
|
-
throw new Error(`Multiple projects allowed (${
|
|
1278
|
+
if (!projectId && allowedProjectIds.length > 1) {
|
|
1279
|
+
throw new Error(`Multiple projects allowed (${allowedProjectIds.join(", ")}). Please specify a project ID.`);
|
|
1232
1280
|
}
|
|
1233
|
-
return projectId ||
|
|
1281
|
+
return projectId || allowedProjectIds[0];
|
|
1234
1282
|
}
|
|
1235
1283
|
// Prioritize the passed projectId over GITLAB_PROJECT_ID to allow querying different projects
|
|
1236
1284
|
if (projectId) {
|
|
@@ -1245,8 +1293,8 @@ function rejectIfProjectScopedDeployment(toolName) {
|
|
|
1245
1293
|
if (GITLAB_PROJECT_ID) {
|
|
1246
1294
|
throw new Error(`${toolName} is not allowed when GITLAB_PROJECT_ID is set (server is locked to a single project)`);
|
|
1247
1295
|
}
|
|
1248
|
-
if (
|
|
1249
|
-
throw new Error(`${toolName} is not allowed
|
|
1296
|
+
if (getEffectiveAllowedProjectIds().length > 0) {
|
|
1297
|
+
throw new Error(`${toolName} is not allowed while a project allowlist is in effect (GITLAB_ALLOWED_PROJECT_IDS or a session scope restricts access to configured projects)`);
|
|
1250
1298
|
}
|
|
1251
1299
|
}
|
|
1252
1300
|
/**
|
|
@@ -1390,9 +1438,11 @@ async function createIssue(projectId, options) {
|
|
|
1390
1438
|
*/
|
|
1391
1439
|
async function listIssues(projectId, options = {}) {
|
|
1392
1440
|
let url;
|
|
1393
|
-
if (projectId) {
|
|
1394
|
-
|
|
1395
|
-
|
|
1441
|
+
if (projectId || (ENABLE_STRICT_PROJECT_SCOPE && getEffectiveAllowedProjectIds().length > 0)) {
|
|
1442
|
+
// In strict scope, never fall back to the instance-wide endpoint;
|
|
1443
|
+
// resolve the default project (or fail for a multi-project allowlist)
|
|
1444
|
+
const decodedProjectId = projectId ? decodeURIComponent(projectId) : "";
|
|
1445
|
+
const effectiveProjectId = getEffectiveProjectId(decodedProjectId);
|
|
1396
1446
|
url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/issues`);
|
|
1397
1447
|
}
|
|
1398
1448
|
else {
|
|
@@ -1486,11 +1536,18 @@ function appendMergeRequestFilters(url, options) {
|
|
|
1486
1536
|
* @returns {Promise<GitLabMergeRequest[]>} List of merge requests
|
|
1487
1537
|
*/
|
|
1488
1538
|
async function listMergeRequests(projectId, options = {}) {
|
|
1489
|
-
const decodedProjectId = projectId ? decodeURIComponent(projectId) :
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1539
|
+
const decodedProjectId = projectId ? decodeURIComponent(projectId) : "";
|
|
1540
|
+
let url;
|
|
1541
|
+
if (decodedProjectId ||
|
|
1542
|
+
(ENABLE_STRICT_PROJECT_SCOPE && getEffectiveAllowedProjectIds().length > 0)) {
|
|
1543
|
+
// In strict scope, never fall back to the instance-wide endpoint;
|
|
1544
|
+
// resolve the default project (or fail for a multi-project allowlist)
|
|
1545
|
+
const effectiveProjectId = getEffectiveProjectId(decodedProjectId);
|
|
1546
|
+
url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/merge_requests`);
|
|
1547
|
+
}
|
|
1548
|
+
else {
|
|
1549
|
+
url = new URL(`${getEffectiveApiUrl()}/merge_requests`);
|
|
1550
|
+
}
|
|
1494
1551
|
appendMergeRequestFilters(url, options);
|
|
1495
1552
|
const response = await fetch(url.toString(), {
|
|
1496
1553
|
...getFetchConfig(),
|
|
@@ -2179,8 +2236,8 @@ async function resolveProjectOrGroupPath(projectId) {
|
|
|
2179
2236
|
const namespaceMatch = decodedProjectId.match(/^(group|project):(.+)$/i);
|
|
2180
2237
|
const explicitKind = namespaceMatch?.[1]?.toLowerCase();
|
|
2181
2238
|
const requestedProjectId = namespaceMatch ? namespaceMatch[2] : decodedProjectId;
|
|
2182
|
-
if (explicitKind === "group" &&
|
|
2183
|
-
throw new Error("group:<id-or-path> cannot be used
|
|
2239
|
+
if (explicitKind === "group" && getEffectiveAllowedProjectIds().length > 0) {
|
|
2240
|
+
throw new Error("group:<id-or-path> cannot be used while a project allowlist is in effect (GITLAB_ALLOWED_PROJECT_IDS or a session scope), because the project allowlist does not cover groups");
|
|
2184
2241
|
}
|
|
2185
2242
|
const effectiveProjectId = getEffectiveProjectId(requestedProjectId);
|
|
2186
2243
|
if (explicitKind === "group") {
|
|
@@ -2202,7 +2259,7 @@ async function resolveProjectOrGroupPath(projectId) {
|
|
|
2202
2259
|
// resolving to an unrelated group with the same integer ID.
|
|
2203
2260
|
if (projectResponse.status === 404 &&
|
|
2204
2261
|
!explicitKind &&
|
|
2205
|
-
|
|
2262
|
+
getEffectiveAllowedProjectIds().length === 0 &&
|
|
2206
2263
|
!/^\d+$/.test(effectiveProjectId)) {
|
|
2207
2264
|
const groupUrl = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(effectiveProjectId)}`);
|
|
2208
2265
|
const groupResponse = await fetch(groupUrl.toString(), {
|
|
@@ -3522,14 +3579,19 @@ async function searchProjects(query, page = 1, perPage = 20) {
|
|
|
3522
3579
|
const errorBody = await response.text();
|
|
3523
3580
|
throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorBody}`);
|
|
3524
3581
|
}
|
|
3525
|
-
const
|
|
3582
|
+
const unfiltered = (await response.json());
|
|
3583
|
+
const projects = filterProjectsByAllowlist(unfiltered);
|
|
3526
3584
|
const totalCount = response.headers.get("x-total");
|
|
3527
3585
|
const totalPages = response.headers.get("x-total-pages");
|
|
3528
|
-
|
|
3586
|
+
const nextPage = response.headers.get("x-next-page");
|
|
3587
|
+
// GitLab API doesn't return the total headers for results > 10,000
|
|
3588
|
+
// Keep the unfiltered totals so callers paginate past pages the allowlist filter thinned out;
|
|
3589
|
+
// without them, total_pages stays unset and next_page carries the continuation signal
|
|
3529
3590
|
const count = totalCount ? Number.parseInt(totalCount, 10) : projects.length;
|
|
3530
3591
|
return GitLabSearchResponseSchema.parse({
|
|
3531
3592
|
count,
|
|
3532
|
-
total_pages: totalPages ? Number.parseInt(totalPages, 10) :
|
|
3593
|
+
total_pages: totalPages ? Number.parseInt(totalPages, 10) : undefined,
|
|
3594
|
+
next_page: nextPage ? Number.parseInt(nextPage, 10) : undefined,
|
|
3533
3595
|
current_page: page,
|
|
3534
3596
|
items: projects,
|
|
3535
3597
|
});
|
|
@@ -4623,7 +4685,7 @@ async function listProjects(options = {}) {
|
|
|
4623
4685
|
await handleGitLabError(response);
|
|
4624
4686
|
// Parse and return the data
|
|
4625
4687
|
const data = await response.json();
|
|
4626
|
-
return z.array(GitLabProjectSchema).parse(data);
|
|
4688
|
+
return filterProjectsByAllowlist(z.array(GitLabProjectSchema).parse(data));
|
|
4627
4689
|
}
|
|
4628
4690
|
/**
|
|
4629
4691
|
* List labels for a project
|
|
@@ -4789,7 +4851,7 @@ async function listGroupProjects(options) {
|
|
|
4789
4851
|
});
|
|
4790
4852
|
await handleGitLabError(response);
|
|
4791
4853
|
const projects = await response.json();
|
|
4792
|
-
return GitLabProjectSchema.array().parse(projects);
|
|
4854
|
+
return filterProjectsByAllowlist(GitLabProjectSchema.array().parse(projects));
|
|
4793
4855
|
}
|
|
4794
4856
|
// Webhook API helper functions
|
|
4795
4857
|
/**
|
|
@@ -4800,6 +4862,7 @@ function buildWebhookBaseUrl(projectId, groupId) {
|
|
|
4800
4862
|
projectId = decodeURIComponent(projectId);
|
|
4801
4863
|
return `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/hooks`;
|
|
4802
4864
|
}
|
|
4865
|
+
rejectIfStrictProjectScope("group webhooks");
|
|
4803
4866
|
const decodedGroupId = decodeURIComponent(groupId);
|
|
4804
4867
|
return `${getEffectiveApiUrl()}/groups/${encodeURIComponent(decodedGroupId)}/hooks`;
|
|
4805
4868
|
}
|
|
@@ -6813,15 +6876,16 @@ async function resolveProjectFullPath(projectId) {
|
|
|
6813
6876
|
* Allowlist entries may be numeric IDs or full namespace paths.
|
|
6814
6877
|
*/
|
|
6815
6878
|
function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
|
|
6816
|
-
|
|
6879
|
+
const allowedProjectIds = getEffectiveAllowedProjectIds();
|
|
6880
|
+
if (allowedProjectIds.length === 0) {
|
|
6817
6881
|
return;
|
|
6818
6882
|
}
|
|
6819
6883
|
const fullPath = project?.fullPath ?? undefined;
|
|
6820
6884
|
const numericId = project?.id?.match(/^gid:\/\/gitlab\/Project\/(\d+)$/)?.[1];
|
|
6821
|
-
const allowed = (fullPath !== undefined &&
|
|
6822
|
-
(numericId !== undefined &&
|
|
6885
|
+
const allowed = (fullPath !== undefined && allowedProjectIds.includes(fullPath)) ||
|
|
6886
|
+
(numericId !== undefined && allowedProjectIds.includes(numericId));
|
|
6823
6887
|
if (!allowed) {
|
|
6824
|
-
throw new Error(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${
|
|
6888
|
+
throw new Error(`Access denied: Vulnerability ${vulnerabilityId} belongs to project ${fullPath ?? numericId ?? "unknown"}, which is not in the allowed project list: ${allowedProjectIds.join(", ")}`);
|
|
6825
6889
|
}
|
|
6826
6890
|
}
|
|
6827
6891
|
/**
|
|
@@ -6831,7 +6895,7 @@ function assertVulnerabilityProjectAllowed(vulnerabilityId, project) {
|
|
|
6831
6895
|
* is not configured.
|
|
6832
6896
|
*/
|
|
6833
6897
|
async function ensureVulnerabilityProjectAllowed(vulnerabilityId) {
|
|
6834
|
-
if (
|
|
6898
|
+
if (getEffectiveAllowedProjectIds().length === 0) {
|
|
6835
6899
|
return;
|
|
6836
6900
|
}
|
|
6837
6901
|
const data = await executeGraphQL(`query getVulnerabilityProject($id: VulnerabilityID!) {
|
|
@@ -7558,6 +7622,7 @@ async function handleToolCall(params) {
|
|
|
7558
7622
|
};
|
|
7559
7623
|
}
|
|
7560
7624
|
case "search_code": {
|
|
7625
|
+
rejectIfStrictProjectScope("search_code");
|
|
7561
7626
|
const args = SearchCodeSchema.parse(params.arguments);
|
|
7562
7627
|
const results = await searchBlobs({
|
|
7563
7628
|
search: args.search,
|
|
@@ -7588,6 +7653,7 @@ async function handleToolCall(params) {
|
|
|
7588
7653
|
};
|
|
7589
7654
|
}
|
|
7590
7655
|
case "search_group_code": {
|
|
7656
|
+
rejectIfStrictProjectScope("search_group_code");
|
|
7591
7657
|
const args = SearchGroupCodeSchema.parse(params.arguments);
|
|
7592
7658
|
const results = await searchBlobs({
|
|
7593
7659
|
search: args.search,
|
|
@@ -7850,6 +7916,7 @@ async function handleToolCall(params) {
|
|
|
7850
7916
|
};
|
|
7851
7917
|
}
|
|
7852
7918
|
case "list_todos": {
|
|
7919
|
+
rejectIfStrictProjectScope("list_todos");
|
|
7853
7920
|
const args = ListTodosSchema.parse(params.arguments);
|
|
7854
7921
|
const todos = await listTodos(args);
|
|
7855
7922
|
return {
|
|
@@ -7857,6 +7924,7 @@ async function handleToolCall(params) {
|
|
|
7857
7924
|
};
|
|
7858
7925
|
}
|
|
7859
7926
|
case "mark_todo_done": {
|
|
7927
|
+
rejectIfStrictProjectScope("mark_todo_done");
|
|
7860
7928
|
const args = MarkTodoDoneSchema.parse(params.arguments);
|
|
7861
7929
|
const todo = await markTodoDone(args.id);
|
|
7862
7930
|
return {
|
|
@@ -7864,6 +7932,7 @@ async function handleToolCall(params) {
|
|
|
7864
7932
|
};
|
|
7865
7933
|
}
|
|
7866
7934
|
case "mark_all_todos_done": {
|
|
7935
|
+
rejectIfStrictProjectScope("mark_all_todos_done");
|
|
7867
7936
|
MarkAllTodosDoneSchema.parse(params.arguments);
|
|
7868
7937
|
await markAllTodosDone();
|
|
7869
7938
|
return {
|
|
@@ -8115,6 +8184,7 @@ async function handleToolCall(params) {
|
|
|
8115
8184
|
};
|
|
8116
8185
|
}
|
|
8117
8186
|
case "list_group_members": {
|
|
8187
|
+
rejectIfStrictProjectScope("list_group_members");
|
|
8118
8188
|
const args = ListGroupMembersSchema.parse(params.arguments);
|
|
8119
8189
|
const { group_id, ...options } = args;
|
|
8120
8190
|
const members = await listGroupMembers(group_id, options);
|
|
@@ -8637,6 +8707,7 @@ async function handleToolCall(params) {
|
|
|
8637
8707
|
};
|
|
8638
8708
|
}
|
|
8639
8709
|
case "list_group_wiki_pages": {
|
|
8710
|
+
rejectIfStrictProjectScope("list_group_wiki_pages");
|
|
8640
8711
|
const { group_id, page, per_page, with_content, render_html } = ListGroupWikiPagesSchema.parse(params.arguments);
|
|
8641
8712
|
const wikiPages = await listGroupWikiPages(group_id, {
|
|
8642
8713
|
page,
|
|
@@ -8649,6 +8720,7 @@ async function handleToolCall(params) {
|
|
|
8649
8720
|
};
|
|
8650
8721
|
}
|
|
8651
8722
|
case "get_group_wiki_page": {
|
|
8723
|
+
rejectIfStrictProjectScope("get_group_wiki_page");
|
|
8652
8724
|
const { group_id, slug, render_html } = GetGroupWikiPageSchema.parse(params.arguments);
|
|
8653
8725
|
const wikiPage = await getGroupWikiPage(group_id, slug, render_html);
|
|
8654
8726
|
return {
|
|
@@ -8656,6 +8728,7 @@ async function handleToolCall(params) {
|
|
|
8656
8728
|
};
|
|
8657
8729
|
}
|
|
8658
8730
|
case "create_group_wiki_page": {
|
|
8731
|
+
rejectIfStrictProjectScope("create_group_wiki_page");
|
|
8659
8732
|
const { group_id, title, content, format } = CreateGroupWikiPageSchema.parse(params.arguments);
|
|
8660
8733
|
const wikiPage = await createGroupWikiPage(group_id, title, content, format);
|
|
8661
8734
|
return {
|
|
@@ -8663,6 +8736,7 @@ async function handleToolCall(params) {
|
|
|
8663
8736
|
};
|
|
8664
8737
|
}
|
|
8665
8738
|
case "update_group_wiki_page": {
|
|
8739
|
+
rejectIfStrictProjectScope("update_group_wiki_page");
|
|
8666
8740
|
const { group_id, slug, title, content, format } = UpdateGroupWikiPageSchema.parse(params.arguments);
|
|
8667
8741
|
const wikiPage = await updateGroupWikiPage(group_id, slug, title, content, format);
|
|
8668
8742
|
return {
|
|
@@ -8670,6 +8744,7 @@ async function handleToolCall(params) {
|
|
|
8670
8744
|
};
|
|
8671
8745
|
}
|
|
8672
8746
|
case "delete_group_wiki_page": {
|
|
8747
|
+
rejectIfStrictProjectScope("delete_group_wiki_page");
|
|
8673
8748
|
const { group_id, slug } = DeleteGroupWikiPageSchema.parse(params.arguments);
|
|
8674
8749
|
await deleteGroupWikiPage(group_id, slug);
|
|
8675
8750
|
return {
|
|
@@ -9192,6 +9267,7 @@ async function handleToolCall(params) {
|
|
|
9192
9267
|
};
|
|
9193
9268
|
}
|
|
9194
9269
|
case "list_group_merge_requests": {
|
|
9270
|
+
rejectIfStrictProjectScope("list_group_merge_requests");
|
|
9195
9271
|
const { group_id, ...options } = ListGroupMergeRequestsSchema.parse(params.arguments);
|
|
9196
9272
|
const cleanedOptions = cleanMutuallyExclusiveIdUsernameOptions(options, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS);
|
|
9197
9273
|
const mergeRequests = await listGroupMergeRequests(group_id, cleanedOptions);
|
|
@@ -9311,6 +9387,7 @@ async function handleToolCall(params) {
|
|
|
9311
9387
|
};
|
|
9312
9388
|
}
|
|
9313
9389
|
case "list_group_milestones": {
|
|
9390
|
+
rejectIfStrictProjectScope("list_group_milestones");
|
|
9314
9391
|
const { group_id, ...options } = ListGroupMilestonesSchema.parse(params.arguments);
|
|
9315
9392
|
const milestones = await listGroupMilestones(group_id, options);
|
|
9316
9393
|
return {
|
|
@@ -9323,6 +9400,7 @@ async function handleToolCall(params) {
|
|
|
9323
9400
|
};
|
|
9324
9401
|
}
|
|
9325
9402
|
case "get_group_milestone": {
|
|
9403
|
+
rejectIfStrictProjectScope("get_group_milestone");
|
|
9326
9404
|
const { group_id, milestone_id } = GetGroupMilestoneSchema.parse(params.arguments);
|
|
9327
9405
|
const milestone = await getGroupMilestone(group_id, milestone_id);
|
|
9328
9406
|
return {
|
|
@@ -9335,6 +9413,7 @@ async function handleToolCall(params) {
|
|
|
9335
9413
|
};
|
|
9336
9414
|
}
|
|
9337
9415
|
case "create_group_milestone": {
|
|
9416
|
+
rejectIfStrictProjectScope("create_group_milestone");
|
|
9338
9417
|
const { group_id, ...options } = CreateGroupMilestoneSchema.parse(params.arguments);
|
|
9339
9418
|
const milestone = await createGroupMilestone(group_id, options);
|
|
9340
9419
|
return {
|
|
@@ -9347,6 +9426,7 @@ async function handleToolCall(params) {
|
|
|
9347
9426
|
};
|
|
9348
9427
|
}
|
|
9349
9428
|
case "edit_group_milestone": {
|
|
9429
|
+
rejectIfStrictProjectScope("edit_group_milestone");
|
|
9350
9430
|
const { group_id, milestone_id, ...options } = EditGroupMilestoneSchema.parse(params.arguments);
|
|
9351
9431
|
const milestone = await editGroupMilestone(group_id, milestone_id, options);
|
|
9352
9432
|
return {
|
|
@@ -9359,6 +9439,7 @@ async function handleToolCall(params) {
|
|
|
9359
9439
|
};
|
|
9360
9440
|
}
|
|
9361
9441
|
case "delete_group_milestone": {
|
|
9442
|
+
rejectIfStrictProjectScope("delete_group_milestone");
|
|
9362
9443
|
const { group_id, milestone_id } = DeleteGroupMilestoneSchema.parse(params.arguments);
|
|
9363
9444
|
await deleteGroupMilestone(group_id, milestone_id);
|
|
9364
9445
|
return {
|
|
@@ -9374,6 +9455,7 @@ async function handleToolCall(params) {
|
|
|
9374
9455
|
};
|
|
9375
9456
|
}
|
|
9376
9457
|
case "get_group_milestone_issue": {
|
|
9458
|
+
rejectIfStrictProjectScope("get_group_milestone_issue");
|
|
9377
9459
|
const { group_id, milestone_id, ...options } = GetGroupMilestoneIssuesSchema.parse(params.arguments);
|
|
9378
9460
|
const issues = await getGroupMilestoneIssues(group_id, milestone_id, options);
|
|
9379
9461
|
return {
|
|
@@ -9386,6 +9468,7 @@ async function handleToolCall(params) {
|
|
|
9386
9468
|
};
|
|
9387
9469
|
}
|
|
9388
9470
|
case "get_group_milestone_merge_requests": {
|
|
9471
|
+
rejectIfStrictProjectScope("get_group_milestone_merge_requests");
|
|
9389
9472
|
const { group_id, milestone_id, ...options } = GetGroupMilestoneMergeRequestsSchema.parse(params.arguments);
|
|
9390
9473
|
const mergeRequests = await getGroupMilestoneMergeRequests(group_id, milestone_id, options);
|
|
9391
9474
|
return {
|
|
@@ -9398,6 +9481,7 @@ async function handleToolCall(params) {
|
|
|
9398
9481
|
};
|
|
9399
9482
|
}
|
|
9400
9483
|
case "get_group_milestone_burndown_events": {
|
|
9484
|
+
rejectIfStrictProjectScope("get_group_milestone_burndown_events");
|
|
9401
9485
|
const { group_id, milestone_id, ...options } = GetGroupMilestoneBurndownEventsSchema.parse(params.arguments);
|
|
9402
9486
|
const events = await getGroupMilestoneBurndownEvents(group_id, milestone_id, options);
|
|
9403
9487
|
return {
|
|
@@ -9455,6 +9539,7 @@ async function handleToolCall(params) {
|
|
|
9455
9539
|
};
|
|
9456
9540
|
}
|
|
9457
9541
|
case "list_group_iterations": {
|
|
9542
|
+
rejectIfStrictProjectScope("list_group_iterations");
|
|
9458
9543
|
const args = ListGroupIterationsSchema.parse(params.arguments);
|
|
9459
9544
|
const iterations = await listGroupIterations(args.group_id, args);
|
|
9460
9545
|
return {
|
|
@@ -9653,6 +9738,7 @@ async function handleToolCall(params) {
|
|
|
9653
9738
|
};
|
|
9654
9739
|
}
|
|
9655
9740
|
case "list_events": {
|
|
9741
|
+
rejectIfStrictProjectScope("list_events");
|
|
9656
9742
|
const args = ListEventsSchema.parse(params.arguments);
|
|
9657
9743
|
const events = await listEvents(args);
|
|
9658
9744
|
return {
|
|
@@ -10248,7 +10334,28 @@ async function startStreamableHTTPServer() {
|
|
|
10248
10334
|
const privateToken = req.headers["private-token"] || "";
|
|
10249
10335
|
const jobToken = req.headers["job-token"] || "";
|
|
10250
10336
|
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
|
|
10337
|
+
const requestedProjectScope = req.headers["x-gitlab-allowed-project-ids"]?.trim();
|
|
10251
10338
|
let apiUrl = GITLAB_API_URL; // Default API URL
|
|
10339
|
+
let allowedProjectIds;
|
|
10340
|
+
// Only process the requested project scope if the feature is enabled
|
|
10341
|
+
if (ENABLE_DYNAMIC_PROJECT_SCOPE && requestedProjectScope !== undefined) {
|
|
10342
|
+
const requested = requestedProjectScope
|
|
10343
|
+
.split(",")
|
|
10344
|
+
.map(id => id.trim())
|
|
10345
|
+
.filter(Boolean);
|
|
10346
|
+
if (requested.length === 0) {
|
|
10347
|
+
logger.warn("Empty X-GitLab-Allowed-Project-Ids provided");
|
|
10348
|
+
return null;
|
|
10349
|
+
}
|
|
10350
|
+
if (GITLAB_ALLOWED_PROJECT_IDS.length > 0) {
|
|
10351
|
+
const outOfScope = requested.filter(id => !GITLAB_ALLOWED_PROJECT_IDS.includes(id));
|
|
10352
|
+
if (outOfScope.length > 0) {
|
|
10353
|
+
logger.warn(`X-GitLab-Allowed-Project-Ids requested projects outside GITLAB_ALLOWED_PROJECT_IDS: ${outOfScope.join(", ")}`);
|
|
10354
|
+
return null; // Reject if the header tries to widen the configured allowlist
|
|
10355
|
+
}
|
|
10356
|
+
}
|
|
10357
|
+
allowedProjectIds = requested;
|
|
10358
|
+
}
|
|
10252
10359
|
// Only process dynamic URL if the feature is enabled
|
|
10253
10360
|
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
|
|
10254
10361
|
try {
|
|
@@ -10284,7 +10391,7 @@ async function startStreamableHTTPServer() {
|
|
|
10284
10391
|
}
|
|
10285
10392
|
// Validate token and return AuthData object
|
|
10286
10393
|
if (token && header && validateToken(token)) {
|
|
10287
|
-
return { header, token, lastUsed: Date.now(), apiUrl };
|
|
10394
|
+
return { header, token, lastUsed: Date.now(), apiUrl, allowedProjectIds };
|
|
10288
10395
|
}
|
|
10289
10396
|
return null;
|
|
10290
10397
|
};
|
|
@@ -10434,6 +10541,7 @@ async function startStreamableHTTPServer() {
|
|
|
10434
10541
|
header: headerAuth.header,
|
|
10435
10542
|
token: headerAuth.token,
|
|
10436
10543
|
apiUrl: headerAuth.apiUrl,
|
|
10544
|
+
allowedProjectIds: headerAuth.allowedProjectIds,
|
|
10437
10545
|
};
|
|
10438
10546
|
freshAuthPresent = true;
|
|
10439
10547
|
}
|
|
@@ -10462,7 +10570,12 @@ async function startStreamableHTTPServer() {
|
|
|
10462
10570
|
if (looksLikeStatelessSessionId(incomingSid)) {
|
|
10463
10571
|
const opened = openSessionId(material, incomingSid, sessionTtlSeconds);
|
|
10464
10572
|
if (opened) {
|
|
10465
|
-
effective = {
|
|
10573
|
+
effective = {
|
|
10574
|
+
header: opened.h,
|
|
10575
|
+
token: opened.t,
|
|
10576
|
+
apiUrl: opened.u,
|
|
10577
|
+
allowedProjectIds: opened.p,
|
|
10578
|
+
};
|
|
10466
10579
|
metrics.statelessAuthFromSealedSid++;
|
|
10467
10580
|
}
|
|
10468
10581
|
else {
|
|
@@ -10508,6 +10621,7 @@ async function startStreamableHTTPServer() {
|
|
|
10508
10621
|
header: effective.header,
|
|
10509
10622
|
token: effective.token,
|
|
10510
10623
|
apiUrl: effective.apiUrl,
|
|
10624
|
+
allowedProjectIds: effective.allowedProjectIds,
|
|
10511
10625
|
});
|
|
10512
10626
|
if (freshAuthPresent) {
|
|
10513
10627
|
metrics.statelessSidRotated++;
|
|
@@ -10526,6 +10640,7 @@ async function startStreamableHTTPServer() {
|
|
|
10526
10640
|
lastUsed: Date.now(),
|
|
10527
10641
|
apiUrl: effective.apiUrl,
|
|
10528
10642
|
publicBaseUrl: getForwardedPublicBaseUrl(req, MCP_TRUST_PROXY),
|
|
10643
|
+
allowedProjectIds: effective.allowedProjectIds,
|
|
10529
10644
|
};
|
|
10530
10645
|
// Step 4: create a fresh transport per request.
|
|
10531
10646
|
// DNS rebinding protection is enforced by requireMcpHostAndOrigin middleware;
|
|
@@ -11052,6 +11167,7 @@ async function startStreamableHTTPServer() {
|
|
|
11052
11167
|
lastUsed: authData.lastUsed,
|
|
11053
11168
|
apiUrl: authData.apiUrl,
|
|
11054
11169
|
publicBaseUrl: authData.publicBaseUrl,
|
|
11170
|
+
allowedProjectIds: authData.allowedProjectIds,
|
|
11055
11171
|
};
|
|
11056
11172
|
// Run the entire request handling within AsyncLocalStorage context
|
|
11057
11173
|
await sessionAuthStore.run(ctx, handleRequest);
|
|
@@ -11130,6 +11246,7 @@ async function startStreamableHTTPServer() {
|
|
|
11130
11246
|
lastUsed: authData.lastUsed,
|
|
11131
11247
|
apiUrl: authData.apiUrl,
|
|
11132
11248
|
publicBaseUrl: authData.publicBaseUrl,
|
|
11249
|
+
allowedProjectIds: authData.allowedProjectIds,
|
|
11133
11250
|
};
|
|
11134
11251
|
await sessionAuthStore.run(ctx, handleGetRequest);
|
|
11135
11252
|
}
|
package/build/schemas.js
CHANGED
|
@@ -1229,6 +1229,7 @@ export const GitLabCreateUpdateFileResponseSchema = z.object({
|
|
|
1229
1229
|
export const GitLabSearchResponseSchema = z.object({
|
|
1230
1230
|
count: z.coerce.number().optional(),
|
|
1231
1231
|
total_pages: z.coerce.number().optional(),
|
|
1232
|
+
next_page: z.coerce.number().optional(),
|
|
1232
1233
|
current_page: z.coerce.number().optional(),
|
|
1233
1234
|
items: z.array(GitLabRepositorySchema),
|
|
1234
1235
|
});
|
|
@@ -35,6 +35,9 @@ export function mintSessionId(material, input) {
|
|
|
35
35
|
h: input.header,
|
|
36
36
|
t: input.token,
|
|
37
37
|
u: input.apiUrl,
|
|
38
|
+
...(input.allowedProjectIds && input.allowedProjectIds.length > 0
|
|
39
|
+
? { p: input.allowedProjectIds }
|
|
40
|
+
: {}),
|
|
38
41
|
};
|
|
39
42
|
return seal(material, STATELESS_PURPOSES.SESSION_ID, payload);
|
|
40
43
|
}
|
|
@@ -51,7 +54,9 @@ export function openSessionId(material, sid, ttlSeconds, now) {
|
|
|
51
54
|
payload.h !== "JOB-TOKEN") ||
|
|
52
55
|
typeof payload.t !== "string" ||
|
|
53
56
|
payload.t.length === 0 ||
|
|
54
|
-
typeof payload.u !== "string"
|
|
57
|
+
typeof payload.u !== "string" ||
|
|
58
|
+
(payload.p !== undefined &&
|
|
59
|
+
(!Array.isArray(payload.p) || payload.p.some(id => typeof id !== "string")))) {
|
|
55
60
|
return null;
|
|
56
61
|
}
|
|
57
62
|
return payload;
|
|
@@ -19,6 +19,32 @@ function load(current, previous) {
|
|
|
19
19
|
return m;
|
|
20
20
|
}
|
|
21
21
|
describe("mintSessionId / openSessionId", () => {
|
|
22
|
+
test("seals and restores a narrowed project scope", () => {
|
|
23
|
+
const s = secret();
|
|
24
|
+
const a = load(s);
|
|
25
|
+
const b = load(s);
|
|
26
|
+
const sid = mintSessionId(a, {
|
|
27
|
+
header: "Private-Token",
|
|
28
|
+
token: "glpat-ABCDEFG123456789-abcdef",
|
|
29
|
+
apiUrl: "https://gitlab.example.com/api/v4",
|
|
30
|
+
allowedProjectIds: ["42", "org/theorg"],
|
|
31
|
+
});
|
|
32
|
+
const opened = openSessionId(b, sid, 3600);
|
|
33
|
+
assert.ok(opened);
|
|
34
|
+
assert.deepEqual(opened.p, ["42", "org/theorg"]);
|
|
35
|
+
});
|
|
36
|
+
test("omits the scope field when no scope was narrowed", () => {
|
|
37
|
+
const s = secret();
|
|
38
|
+
const m = load(s);
|
|
39
|
+
const sid = mintSessionId(m, {
|
|
40
|
+
header: "Private-Token",
|
|
41
|
+
token: "glpat-ABCDEFG123456789-abcdef",
|
|
42
|
+
apiUrl: "https://gitlab.example.com/api/v4",
|
|
43
|
+
});
|
|
44
|
+
const opened = openSessionId(m, sid, 3600);
|
|
45
|
+
assert.ok(opened);
|
|
46
|
+
assert.equal(opened.p, undefined);
|
|
47
|
+
});
|
|
22
48
|
test("roundtrips across pods", () => {
|
|
23
49
|
const s = secret();
|
|
24
50
|
const a = load(s);
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { describe, test, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
+
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
6
|
+
const MOCK_TOKEN = "glpat-dynamic-scope-token";
|
|
7
|
+
const SCOPE_HEADER = "x-gitlab-allowed-project-ids";
|
|
8
|
+
async function connectClient(mcpUrl, scope) {
|
|
9
|
+
const headers = { authorization: `Bearer ${MOCK_TOKEN}` };
|
|
10
|
+
if (scope !== undefined) {
|
|
11
|
+
headers[SCOPE_HEADER] = scope;
|
|
12
|
+
}
|
|
13
|
+
const client = new CustomHeaderClient(headers);
|
|
14
|
+
await client.connect(mcpUrl);
|
|
15
|
+
return client;
|
|
16
|
+
}
|
|
17
|
+
async function getProjectId(client, projectId) {
|
|
18
|
+
const result = await client.callTool("get_project", { project_id: projectId });
|
|
19
|
+
assert.ok(result.content, "Should have content");
|
|
20
|
+
const content = result.content[0];
|
|
21
|
+
assert.ok("text" in content, "Content should have text");
|
|
22
|
+
const project = JSON.parse(content.text);
|
|
23
|
+
return project.id.toString();
|
|
24
|
+
}
|
|
25
|
+
describe("Dynamic project scope", { concurrency: 1 }, () => {
|
|
26
|
+
describe("with GITLAB_ALLOWED_PROJECT_IDS set", () => {
|
|
27
|
+
let mockGitLab;
|
|
28
|
+
const servers = [];
|
|
29
|
+
let mcpUrl;
|
|
30
|
+
before(async () => {
|
|
31
|
+
const mockPort = await findMockServerPort();
|
|
32
|
+
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
|
|
33
|
+
mockGitLab.addMockHandler("get", "/projects", (req, res) => {
|
|
34
|
+
if (req.query.search === "no-totals") {
|
|
35
|
+
const requestedPage = Number(req.query.page ?? "1");
|
|
36
|
+
if (requestedPage < 3) {
|
|
37
|
+
res.set("x-next-page", String(requestedPage + 1));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
res.set("x-total", "3");
|
|
42
|
+
res.set("x-total-pages", "2");
|
|
43
|
+
}
|
|
44
|
+
res.json(["1", "2", "3"].map(id => ({
|
|
45
|
+
id: Number(id),
|
|
46
|
+
name: `Project ${id}`,
|
|
47
|
+
path: `project-${id}`,
|
|
48
|
+
path_with_namespace: `group/project-${id}`,
|
|
49
|
+
description: null,
|
|
50
|
+
visibility: "private",
|
|
51
|
+
namespace: { id: 1, name: "Group", path: "group", kind: "group", full_path: "group" },
|
|
52
|
+
})));
|
|
53
|
+
});
|
|
54
|
+
await mockGitLab.start();
|
|
55
|
+
const mcpPort = await findAvailablePort(3200);
|
|
56
|
+
const server = await launchServer({
|
|
57
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
58
|
+
port: mcpPort,
|
|
59
|
+
timeout: 5000,
|
|
60
|
+
env: {
|
|
61
|
+
STREAMABLE_HTTP: "true",
|
|
62
|
+
REMOTE_AUTHORIZATION: "true",
|
|
63
|
+
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
64
|
+
ENABLE_STRICT_PROJECT_SCOPE: "true",
|
|
65
|
+
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
66
|
+
GITLAB_ALLOWED_PROJECT_IDS: "1,2",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
servers.push(server);
|
|
70
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
71
|
+
});
|
|
72
|
+
after(async () => {
|
|
73
|
+
cleanupServers(servers);
|
|
74
|
+
await mockGitLab?.stop();
|
|
75
|
+
});
|
|
76
|
+
test("header narrows the allowlist to the requested projects", async () => {
|
|
77
|
+
const client = await connectClient(mcpUrl, "1");
|
|
78
|
+
try {
|
|
79
|
+
assert.strictEqual(await getProjectId(client, ""), "1");
|
|
80
|
+
await assert.rejects(() => client.callTool("get_project", { project_id: "2" }), /Access denied/);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
await client.disconnect();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
test("project outside GITLAB_ALLOWED_PROJECT_IDS rejects the session", async () => {
|
|
87
|
+
const client = new CustomHeaderClient({
|
|
88
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
89
|
+
[SCOPE_HEADER]: "1,3",
|
|
90
|
+
});
|
|
91
|
+
let connected = false;
|
|
92
|
+
try {
|
|
93
|
+
await client.connect(mcpUrl);
|
|
94
|
+
connected = true;
|
|
95
|
+
await client.callTool("get_project", { project_id: "1" });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Expected: the session is rejected before any tool call is possible.
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
await client.disconnect();
|
|
102
|
+
}
|
|
103
|
+
assert.strictEqual(connected, false, "out-of-scope header must not initialize a session");
|
|
104
|
+
});
|
|
105
|
+
test("empty header rejects the session", async () => {
|
|
106
|
+
const client = new CustomHeaderClient({
|
|
107
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
108
|
+
[SCOPE_HEADER]: " ",
|
|
109
|
+
});
|
|
110
|
+
let connected = false;
|
|
111
|
+
try {
|
|
112
|
+
await client.connect(mcpUrl);
|
|
113
|
+
connected = true;
|
|
114
|
+
await client.callTool("get_project", { project_id: "1" });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
// Expected: the session is rejected before any tool call is possible.
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await client.disconnect();
|
|
121
|
+
}
|
|
122
|
+
assert.strictEqual(connected, false, "an empty header must not initialize a session");
|
|
123
|
+
});
|
|
124
|
+
test("scoped session refuses non-scopable tools", async () => {
|
|
125
|
+
const client = await connectClient(mcpUrl, "1");
|
|
126
|
+
try {
|
|
127
|
+
await assert.rejects(() => client.callTool("execute_graphql", { query: "query { currentUser { id } }" }), /not allowed while a project allowlist is in effect/);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
await client.disconnect();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
test("session without the header keeps the full env allowlist", async () => {
|
|
134
|
+
const client = await connectClient(mcpUrl);
|
|
135
|
+
try {
|
|
136
|
+
assert.strictEqual(await getProjectId(client, "2"), "2");
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await client.disconnect();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
test("list_issues without project_id stays within the session scope", async () => {
|
|
143
|
+
const client = await connectClient(mcpUrl, "1");
|
|
144
|
+
try {
|
|
145
|
+
const result = await client.callTool("list_issues", {});
|
|
146
|
+
assert.ok(result.content, "Should have content");
|
|
147
|
+
const content = result.content[0];
|
|
148
|
+
assert.ok("text" in content, "Content should have text");
|
|
149
|
+
const issues = JSON.parse(content.text);
|
|
150
|
+
assert.ok(issues.every(issue => issue.web_url.includes("/project/1/")), "Should only return issues of the scoped project");
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
await client.disconnect();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
test("list_todos is refused under strict scope", async () => {
|
|
157
|
+
const client = await connectClient(mcpUrl, "1");
|
|
158
|
+
try {
|
|
159
|
+
await assert.rejects(() => client.callTool("list_todos", {}), /not allowed while a project allowlist is in effect/);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
await client.disconnect();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
test("search_repositories keeps the unfiltered pagination totals", async () => {
|
|
166
|
+
const client = await connectClient(mcpUrl, "1");
|
|
167
|
+
try {
|
|
168
|
+
const result = await client.callTool("search_repositories", { search: "project" });
|
|
169
|
+
assert.ok(result.content, "Should have content");
|
|
170
|
+
const content = result.content[0];
|
|
171
|
+
assert.ok("text" in content, "Content should have text");
|
|
172
|
+
const search = JSON.parse(content.text);
|
|
173
|
+
assert.deepStrictEqual(search.items.map(item => item.id), ["1"]);
|
|
174
|
+
assert.strictEqual(search.count, 3, "count must reflect the unfiltered GitLab total");
|
|
175
|
+
assert.strictEqual(search.total_pages, 2, "total_pages must cover the unfiltered pages");
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await client.disconnect();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
test("search_repositories follows next_page across all pages when totals are missing", async () => {
|
|
182
|
+
const client = await connectClient(mcpUrl, "1");
|
|
183
|
+
try {
|
|
184
|
+
let page = 1;
|
|
185
|
+
const visited = [];
|
|
186
|
+
while (page !== undefined) {
|
|
187
|
+
const result = await client.callTool("search_repositories", {
|
|
188
|
+
search: "no-totals",
|
|
189
|
+
page,
|
|
190
|
+
});
|
|
191
|
+
assert.ok(result.content, "Should have content");
|
|
192
|
+
const content = result.content[0];
|
|
193
|
+
assert.ok("text" in content, "Content should have text");
|
|
194
|
+
const search = JSON.parse(content.text);
|
|
195
|
+
assert.strictEqual(search.total_pages, undefined, "total_pages must stay unset");
|
|
196
|
+
assert.deepStrictEqual(search.items.map(item => item.id), ["1"]);
|
|
197
|
+
visited.push(search.current_page);
|
|
198
|
+
page = search.next_page;
|
|
199
|
+
}
|
|
200
|
+
assert.deepStrictEqual(visited, [1, 2, 3], "next_page must make every page reachable");
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await client.disconnect();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
test("list_projects is filtered to the session scope", async () => {
|
|
207
|
+
const client = await connectClient(mcpUrl, "1");
|
|
208
|
+
try {
|
|
209
|
+
const result = await client.callTool("list_projects", {});
|
|
210
|
+
assert.ok(result.content, "Should have content");
|
|
211
|
+
const content = result.content[0];
|
|
212
|
+
assert.ok("text" in content, "Content should have text");
|
|
213
|
+
const projects = JSON.parse(content.text);
|
|
214
|
+
assert.deepStrictEqual(projects.map(project => project.id), ["1"]);
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
await client.disconnect();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
test("list_projects is filtered to the env allowlist without the header", async () => {
|
|
221
|
+
const client = await connectClient(mcpUrl);
|
|
222
|
+
try {
|
|
223
|
+
const result = await client.callTool("list_projects", {});
|
|
224
|
+
assert.ok(result.content, "Should have content");
|
|
225
|
+
const content = result.content[0];
|
|
226
|
+
assert.ok("text" in content, "Content should have text");
|
|
227
|
+
const projects = JSON.parse(content.text);
|
|
228
|
+
assert.deepStrictEqual(projects.map(project => project.id), ["1", "2"]);
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
await client.disconnect();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
describe("without GITLAB_ALLOWED_PROJECT_IDS", () => {
|
|
236
|
+
let mockGitLab;
|
|
237
|
+
const servers = [];
|
|
238
|
+
let mcpUrl;
|
|
239
|
+
before(async () => {
|
|
240
|
+
const mockPort = await findMockServerPort();
|
|
241
|
+
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
|
|
242
|
+
mockGitLab.addRootHandler("post", "/api/graphql", (req, res) => {
|
|
243
|
+
const { query } = req.body;
|
|
244
|
+
if (query.includes("vulnerability(")) {
|
|
245
|
+
res.json({
|
|
246
|
+
data: {
|
|
247
|
+
vulnerability: {
|
|
248
|
+
id: "gid://gitlab/Vulnerability/42",
|
|
249
|
+
title: "Mock vulnerability",
|
|
250
|
+
project: {
|
|
251
|
+
id: "gid://gitlab/Project/8",
|
|
252
|
+
name: "Other project",
|
|
253
|
+
fullPath: "other/project",
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
res.status(500).json({ message: `Unexpected GraphQL query: ${query}` });
|
|
261
|
+
});
|
|
262
|
+
await mockGitLab.start();
|
|
263
|
+
const mcpPort = await findAvailablePort(3200);
|
|
264
|
+
const server = await launchServer({
|
|
265
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
266
|
+
port: mcpPort,
|
|
267
|
+
timeout: 5000,
|
|
268
|
+
env: {
|
|
269
|
+
STREAMABLE_HTTP: "true",
|
|
270
|
+
REMOTE_AUTHORIZATION: "true",
|
|
271
|
+
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
|
|
272
|
+
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
servers.push(server);
|
|
276
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
277
|
+
});
|
|
278
|
+
after(async () => {
|
|
279
|
+
cleanupServers(servers);
|
|
280
|
+
await mockGitLab?.stop();
|
|
281
|
+
});
|
|
282
|
+
test("header bounds an otherwise unrestricted server", async () => {
|
|
283
|
+
const client = await connectClient(mcpUrl, "7");
|
|
284
|
+
try {
|
|
285
|
+
assert.strictEqual(await getProjectId(client, ""), "7");
|
|
286
|
+
await assert.rejects(() => client.callTool("get_project", { project_id: "8" }), /Access denied/);
|
|
287
|
+
await assert.rejects(() => client.callTool("execute_graphql", { query: "query { currentUser { id } }" }), /not allowed while a project allowlist is in effect/);
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
await client.disconnect();
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
test("session without the header stays unrestricted", async () => {
|
|
294
|
+
const client = await connectClient(mcpUrl);
|
|
295
|
+
try {
|
|
296
|
+
assert.strictEqual(await getProjectId(client, "8"), "8");
|
|
297
|
+
}
|
|
298
|
+
finally {
|
|
299
|
+
await client.disconnect();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
test("scoped session cannot reach vulnerabilities of other projects", async () => {
|
|
303
|
+
const client = await connectClient(mcpUrl, "7");
|
|
304
|
+
try {
|
|
305
|
+
await assert.rejects(() => client.callTool("get_vulnerability", { vulnerability_id: "42" }), /Access denied/);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
await client.disconnect();
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
test("unscoped session can reach vulnerabilities", async () => {
|
|
312
|
+
const client = await connectClient(mcpUrl);
|
|
313
|
+
try {
|
|
314
|
+
const result = await client.callTool("get_vulnerability", { vulnerability_id: "42" });
|
|
315
|
+
assert.ok(result.content, "Should have content");
|
|
316
|
+
const content = result.content[0];
|
|
317
|
+
assert.ok("text" in content, "Content should have text");
|
|
318
|
+
const vulnerability = JSON.parse(content.text);
|
|
319
|
+
assert.strictEqual(vulnerability.project.fullPath, "other/project");
|
|
320
|
+
}
|
|
321
|
+
finally {
|
|
322
|
+
await client.disconnect();
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
describe("with ENABLE_DYNAMIC_PROJECT_SCOPE disabled", () => {
|
|
327
|
+
let mockGitLab;
|
|
328
|
+
const servers = [];
|
|
329
|
+
let mcpUrl;
|
|
330
|
+
before(async () => {
|
|
331
|
+
const mockPort = await findMockServerPort();
|
|
332
|
+
mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
|
|
333
|
+
await mockGitLab.start();
|
|
334
|
+
const mcpPort = await findAvailablePort(3200);
|
|
335
|
+
const server = await launchServer({
|
|
336
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
337
|
+
port: mcpPort,
|
|
338
|
+
timeout: 5000,
|
|
339
|
+
env: {
|
|
340
|
+
STREAMABLE_HTTP: "true",
|
|
341
|
+
REMOTE_AUTHORIZATION: "true",
|
|
342
|
+
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
servers.push(server);
|
|
346
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
347
|
+
});
|
|
348
|
+
after(async () => {
|
|
349
|
+
cleanupServers(servers);
|
|
350
|
+
await mockGitLab?.stop();
|
|
351
|
+
});
|
|
352
|
+
test("header is ignored when the feature flag is off", async () => {
|
|
353
|
+
const client = await connectClient(mcpUrl, "1");
|
|
354
|
+
try {
|
|
355
|
+
assert.strictEqual(await getProjectId(client, "2"), "2");
|
|
356
|
+
}
|
|
357
|
+
finally {
|
|
358
|
+
await client.disconnect();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
});
|
package/package.json
CHANGED