@zereight/mcp-gitlab 2.1.18 → 2.1.19
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 -0
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/build/config.js +7 -0
- package/build/index.js +104 -6
- package/build/oauth-proxy.js +75 -27
- package/build/schemas.js +73 -0
- package/build/test/mcp-oauth-tests.js +193 -10
- package/build/test/remote-auth-simple-test.js +26 -1
- package/build/test/stateless/callback-proxy.test.js +1 -1
- package/build/test/stateless/client-id.test.js +1 -0
- package/build/test/test-protected-branches.js +155 -0
- package/build/test/test-toolset-filtering.js +1 -1
- package/build/tools/registry.js +36 -1
- package/package.json +2 -2
package/README.ko.md
CHANGED
|
@@ -187,6 +187,7 @@ MCP 서버가 직접 로컬 브라우저 callback을 받을 때만 `GITLAB_OAUTH
|
|
|
187
187
|
| `STREAMABLE_HTTP` | 예 | 반드시 `true` |
|
|
188
188
|
| `GITLAB_OAUTH_CALLBACK_PROXY` | 선택 | MCP 서버의 고정 `/callback` URL을 사용하려면 `true` |
|
|
189
189
|
| `GITLAB_OAUTH_SCOPES` | 선택 | 쉼표로 구분된 scope 목록(기본값: `api,read_api,read_user`) |
|
|
190
|
+
| `GITLAB_ALLOWED_GROUPS` | 선택 | 쉼표로 구분된 GitLab 그룹 전체 경로 — 해당 그룹 및 하위 그룹 멤버만 토큰을 발급받을 수 있음 |
|
|
190
191
|
|
|
191
192
|
> **`Unregistered redirect_uri` 문제 해결**
|
|
192
193
|
>
|
package/README.md
CHANGED
|
@@ -208,6 +208,7 @@ exchanging credentials with GitLab on behalf of the client.
|
|
|
208
208
|
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
209
209
|
| `GITLAB_OAUTH_CALLBACK_PROXY` | optional | Set to `true` to use the MCP server's fixed `/callback` URL |
|
|
210
210
|
| `GITLAB_OAUTH_SCOPES` | optional | Comma-separated scopes (default: `api,read_api,read_user`) |
|
|
211
|
+
| `GITLAB_ALLOWED_GROUPS` | optional | Comma-separated group full paths — only members (and subgroup members) may obtain a token |
|
|
211
212
|
|
|
212
213
|
When `STREAMABLE_HTTP=true`, server-side `GITLAB_PERSONAL_ACCESS_TOKEN` or `GITLAB_JOB_TOKEN` require `REMOTE_AUTHORIZATION=true` or `GITLAB_MCP_OAUTH=true`.
|
|
213
214
|
|
package/README.zh-CN.md
CHANGED
|
@@ -187,6 +187,7 @@ OpenCode、MCPJam、Claude.ai 等远程 MCP 客户端可能会在授权时发送
|
|
|
187
187
|
| `STREAMABLE_HTTP` | 是 | 必须为 `true` |
|
|
188
188
|
| `GITLAB_OAUTH_CALLBACK_PROXY` | 可选 | 设置为 `true` 时使用 MCP 服务器固定的 `/callback` URL |
|
|
189
189
|
| `GITLAB_OAUTH_SCOPES` | 可选 | 逗号分隔的 scope(默认:`api,read_api,read_user`) |
|
|
190
|
+
| `GITLAB_ALLOWED_GROUPS` | 可选 | 逗号分隔的 GitLab 群组完整路径 — 仅该群组及其子群组的成员可获取令牌 |
|
|
190
191
|
|
|
191
192
|
> **排查 `Unregistered redirect_uri`**
|
|
192
193
|
>
|
package/build/config.js
CHANGED
|
@@ -57,6 +57,13 @@ export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
|
|
|
57
57
|
? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
|
|
58
58
|
: undefined;
|
|
59
59
|
export const GITLAB_OAUTH_CALLBACK_PROXY = getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true";
|
|
60
|
+
export const GITLAB_ALLOWED_GROUPS = (() => {
|
|
61
|
+
const raw = getConfig("allowed-groups", "GITLAB_ALLOWED_GROUPS");
|
|
62
|
+
if (!raw)
|
|
63
|
+
return undefined;
|
|
64
|
+
const groups = raw.split(",").map((g) => g.trim()).filter(Boolean);
|
|
65
|
+
return groups.length > 0 ? groups : undefined;
|
|
66
|
+
})();
|
|
60
67
|
export const ENABLE_DYNAMIC_API_URL = getConfig("enable-dynamic-api-url", "ENABLE_DYNAMIC_API_URL") === "true";
|
|
61
68
|
// ---------------------------------------------------------------------------
|
|
62
69
|
// Stateless mode (multi-pod safe OAuth / session encoding)
|
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_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_READ_ONLY_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, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, } from "./config.js";
|
|
2
|
+
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, 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_READ_ONLY_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, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_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
|
/**
|
|
@@ -134,12 +134,12 @@ import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middlew
|
|
|
134
134
|
import { GitLabClientPool } from "./gitlab-client-pool.js";
|
|
135
135
|
import { allTools, readOnlyTools, destructiveTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
|
|
136
136
|
import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
|
|
137
|
-
CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
|
|
137
|
+
CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateGroupSchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, UnprotectBranchSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetBranchSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GitLabBlameEntrySchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema, GitLabCiLintResultSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetUserSchema, GitLabUserFullSchema, WhoAmISchema, GitLabCurrentUserSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCommitStatusSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
|
|
138
138
|
// Discussion Schemas
|
|
139
139
|
GitLabDiscussionNoteSchema, // Added
|
|
140
140
|
GitLabDiscussionSchema,
|
|
141
141
|
// Draft Notes Schemas
|
|
142
|
-
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
142
|
+
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
143
143
|
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
|
|
144
144
|
import { randomUUID, createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
|
|
145
145
|
import { pino } from "pino";
|
|
@@ -8315,6 +8315,97 @@ async function handleToolCall(params) {
|
|
|
8315
8315
|
content: [{ type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }, null, 2) }],
|
|
8316
8316
|
};
|
|
8317
8317
|
}
|
|
8318
|
+
case "list_protected_branches": {
|
|
8319
|
+
const args = ListProtectedBranchesSchema.parse(params.arguments);
|
|
8320
|
+
const projectId = decodeURIComponent(args.project_id);
|
|
8321
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
8322
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/protected_branches`);
|
|
8323
|
+
if (args.search)
|
|
8324
|
+
url.searchParams.append("search", args.search);
|
|
8325
|
+
if (args.page)
|
|
8326
|
+
url.searchParams.append("page", String(args.page));
|
|
8327
|
+
if (args.per_page)
|
|
8328
|
+
url.searchParams.append("per_page", String(args.per_page));
|
|
8329
|
+
const response = await fetch(url.toString(), {
|
|
8330
|
+
...getFetchConfig(),
|
|
8331
|
+
});
|
|
8332
|
+
await handleGitLabError(response);
|
|
8333
|
+
const data = z.array(GitLabProtectedBranchSchema).parse(await response.json());
|
|
8334
|
+
return {
|
|
8335
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
8336
|
+
};
|
|
8337
|
+
}
|
|
8338
|
+
case "get_protected_branch": {
|
|
8339
|
+
const args = GetProtectedBranchSchema.parse(params.arguments);
|
|
8340
|
+
const projectId = decodeURIComponent(args.project_id);
|
|
8341
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
8342
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/protected_branches/${encodeURIComponent(args.branch_name)}`);
|
|
8343
|
+
const response = await fetch(url.toString(), {
|
|
8344
|
+
...getFetchConfig(),
|
|
8345
|
+
});
|
|
8346
|
+
await handleGitLabError(response);
|
|
8347
|
+
const data = GitLabProtectedBranchSchema.parse(await response.json());
|
|
8348
|
+
return {
|
|
8349
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
8350
|
+
};
|
|
8351
|
+
}
|
|
8352
|
+
case "protect_branch": {
|
|
8353
|
+
const args = ProtectBranchSchema.parse(params.arguments);
|
|
8354
|
+
const projectId = decodeURIComponent(args.project_id);
|
|
8355
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
8356
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/protected_branches`);
|
|
8357
|
+
const body = { name: args.branch_name };
|
|
8358
|
+
if (args.push_access_level !== undefined)
|
|
8359
|
+
body.push_access_level = args.push_access_level;
|
|
8360
|
+
if (args.merge_access_level !== undefined)
|
|
8361
|
+
body.merge_access_level = args.merge_access_level;
|
|
8362
|
+
if (args.unprotect_access_level !== undefined)
|
|
8363
|
+
body.unprotect_access_level = args.unprotect_access_level;
|
|
8364
|
+
if (args.allow_force_push !== undefined)
|
|
8365
|
+
body.allow_force_push = args.allow_force_push;
|
|
8366
|
+
if (args.code_owner_approval_required !== undefined)
|
|
8367
|
+
body.code_owner_approval_required = args.code_owner_approval_required;
|
|
8368
|
+
const response = await fetch(url.toString(), {
|
|
8369
|
+
...getFetchConfig(),
|
|
8370
|
+
method: "POST",
|
|
8371
|
+
body: JSON.stringify(body),
|
|
8372
|
+
});
|
|
8373
|
+
await handleGitLabError(response);
|
|
8374
|
+
const data = GitLabProtectedBranchSchema.parse(await response.json());
|
|
8375
|
+
return {
|
|
8376
|
+
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
8377
|
+
};
|
|
8378
|
+
}
|
|
8379
|
+
case "unprotect_branch": {
|
|
8380
|
+
const args = UnprotectBranchSchema.parse(params.arguments);
|
|
8381
|
+
const projectId = decodeURIComponent(args.project_id);
|
|
8382
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
8383
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/protected_branches/${encodeURIComponent(args.branch_name)}`);
|
|
8384
|
+
const response = await fetch(url.toString(), {
|
|
8385
|
+
...getFetchConfig(),
|
|
8386
|
+
method: "DELETE",
|
|
8387
|
+
});
|
|
8388
|
+
await handleGitLabError(response);
|
|
8389
|
+
return {
|
|
8390
|
+
content: [{ type: "text", text: JSON.stringify({ status: "unprotected", branch: args.branch_name }, null, 2) }],
|
|
8391
|
+
};
|
|
8392
|
+
}
|
|
8393
|
+
case "update_default_branch": {
|
|
8394
|
+
const args = UpdateDefaultBranchSchema.parse(params.arguments);
|
|
8395
|
+
const projectId = decodeURIComponent(args.project_id);
|
|
8396
|
+
const effectiveProjectId = getEffectiveProjectId(projectId);
|
|
8397
|
+
const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
|
|
8398
|
+
const response = await fetch(url.toString(), {
|
|
8399
|
+
...getFetchConfig(),
|
|
8400
|
+
method: "PUT",
|
|
8401
|
+
body: JSON.stringify({ default_branch: args.default_branch }),
|
|
8402
|
+
});
|
|
8403
|
+
await handleGitLabError(response);
|
|
8404
|
+
const data = await response.json();
|
|
8405
|
+
return {
|
|
8406
|
+
content: [{ type: "text", text: JSON.stringify({ status: "updated", default_branch: args.default_branch, project: data }, null, 2) }],
|
|
8407
|
+
};
|
|
8408
|
+
}
|
|
8318
8409
|
default:
|
|
8319
8410
|
throw new Error(`Unknown tool: ${params.name}`);
|
|
8320
8411
|
}
|
|
@@ -8719,9 +8810,9 @@ async function startStreamableHTTPServer() {
|
|
|
8719
8810
|
return null;
|
|
8720
8811
|
};
|
|
8721
8812
|
/**
|
|
8722
|
-
* Set or reset timeout for session auth
|
|
8813
|
+
* Set or reset timeout for session auth.
|
|
8723
8814
|
* After SESSION_TIMEOUT_SECONDS of inactivity, the auth token is removed
|
|
8724
|
-
*
|
|
8815
|
+
* and the Streamable HTTP transport is closed so the session slot is released.
|
|
8725
8816
|
*/
|
|
8726
8817
|
const setAuthTimeout = (sessionId) => {
|
|
8727
8818
|
// Clear existing timeout if any
|
|
@@ -8733,6 +8824,13 @@ async function startStreamableHTTPServer() {
|
|
|
8733
8824
|
delete authBySession[sessionId];
|
|
8734
8825
|
delete authTimeouts[sessionId];
|
|
8735
8826
|
metrics.expiredSessions++;
|
|
8827
|
+
// Close the transport to free the slot; without this, stale sessions accumulate and exhaust MAX_SESSIONS.
|
|
8828
|
+
const transport = streamableTransports[sessionId];
|
|
8829
|
+
if (transport) {
|
|
8830
|
+
transport.close().catch(err => {
|
|
8831
|
+
logger.error(`Error closing transport for expired session ${sessionId}:`, err);
|
|
8832
|
+
});
|
|
8833
|
+
}
|
|
8736
8834
|
}
|
|
8737
8835
|
}, SESSION_TIMEOUT_SECONDS * 1000);
|
|
8738
8836
|
};
|
|
@@ -8957,7 +9055,7 @@ async function startStreamableHTTPServer() {
|
|
|
8957
9055
|
storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
|
|
8958
9056
|
}
|
|
8959
9057
|
: null;
|
|
8960
|
-
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions);
|
|
9058
|
+
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_ALLOWED_GROUPS, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions);
|
|
8961
9059
|
const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
|
|
8962
9060
|
// When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
|
|
8963
9061
|
// advertises root-level endpoints. Override to use path-prefixed endpoints.
|
package/build/oauth-proxy.js
CHANGED
|
@@ -127,7 +127,11 @@ class GitLabOAuthServerProvider {
|
|
|
127
127
|
// serialised into opaque OAuth values and the in-memory caches above are
|
|
128
128
|
// bypassed. Enabled independently of callback-proxy mode.
|
|
129
129
|
_stateless;
|
|
130
|
-
|
|
130
|
+
// Group allowlist (optional). When set, tokens are rejected unless the
|
|
131
|
+
// authenticated user is a direct or inherited member of at least one group.
|
|
132
|
+
// Checked once at token issuance (exchangeAuthorizationCode), not per request.
|
|
133
|
+
_allowedGroups;
|
|
134
|
+
constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, allowedGroups, callbackProxyEnabled = false, callbackUrl = "", stateless = null) {
|
|
131
135
|
this._gitlabBaseUrl = gitlabBaseUrl;
|
|
132
136
|
this._gitlabAppId = gitlabAppId;
|
|
133
137
|
this._resourceName = resourceName;
|
|
@@ -137,6 +141,7 @@ class GitLabOAuthServerProvider {
|
|
|
137
141
|
: readOnly
|
|
138
142
|
? REQUIRED_GITLAB_SCOPES_RO
|
|
139
143
|
: REQUIRED_GITLAB_SCOPES_RW;
|
|
144
|
+
this._allowedGroups = allowedGroups ?? null;
|
|
140
145
|
this._callbackProxyEnabled = callbackProxyEnabled;
|
|
141
146
|
this._callbackUrl = callbackUrl;
|
|
142
147
|
this._stateless = stateless;
|
|
@@ -313,6 +318,7 @@ class GitLabOAuthServerProvider {
|
|
|
313
318
|
}
|
|
314
319
|
// ---- Token exchange ----------------------------------------------------
|
|
315
320
|
async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
|
|
321
|
+
let tokens;
|
|
316
322
|
if (this._callbackProxyEnabled) {
|
|
317
323
|
// --- Callback proxy mode ---
|
|
318
324
|
// The authorizationCode is a proxy code we generated in handleCallback().
|
|
@@ -373,32 +379,74 @@ class GitLabOAuthServerProvider {
|
|
|
373
379
|
throw new ServerError("PKCE verification failed");
|
|
374
380
|
}
|
|
375
381
|
}
|
|
376
|
-
|
|
382
|
+
tokens = entry.tokens;
|
|
377
383
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
384
|
+
else {
|
|
385
|
+
// --- Passthrough mode (original behavior) ---
|
|
386
|
+
const params = new URLSearchParams({
|
|
387
|
+
grant_type: "authorization_code",
|
|
388
|
+
client_id: this._gitlabAppId,
|
|
389
|
+
code: authorizationCode,
|
|
390
|
+
});
|
|
391
|
+
if (codeVerifier)
|
|
392
|
+
params.append("code_verifier", codeVerifier);
|
|
393
|
+
if (redirectUri)
|
|
394
|
+
params.append("redirect_uri", redirectUri);
|
|
395
|
+
if (resource)
|
|
396
|
+
params.append("resource", resource.href);
|
|
397
|
+
const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
398
|
+
method: "POST",
|
|
399
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
400
|
+
body: params.toString(),
|
|
401
|
+
});
|
|
402
|
+
if (!response.ok) {
|
|
403
|
+
const body = await response.text();
|
|
404
|
+
logger.error(`Token exchange failed (${response.status}): ${body}`);
|
|
405
|
+
throw new ServerError(`Token exchange failed: ${response.status}`);
|
|
406
|
+
}
|
|
407
|
+
const data = await response.json();
|
|
408
|
+
tokens = OAuthTokensSchema.parse(data);
|
|
399
409
|
}
|
|
400
|
-
|
|
401
|
-
|
|
410
|
+
if (this._allowedGroups) {
|
|
411
|
+
const isMember = await this._checkGroupMembership(tokens.access_token);
|
|
412
|
+
if (!isMember) {
|
|
413
|
+
logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
|
|
414
|
+
throw new ServerError("Access denied: user is not a member of an allowed group");
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return tokens;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Returns true if the token owner belongs to at least one group whose
|
|
421
|
+
* full_path equals or is a sub-path of any configured allowed group.
|
|
422
|
+
*
|
|
423
|
+
* Example: allowedGroups=["my-org"] allows members of "my-org",
|
|
424
|
+
* "my-org/team-a", "my-org/team-a/squad-1", etc.
|
|
425
|
+
*/
|
|
426
|
+
async _checkGroupMembership(token) {
|
|
427
|
+
const allowedPaths = this._allowedGroups.map((g) => g.toLowerCase());
|
|
428
|
+
let page = 1;
|
|
429
|
+
while (true) {
|
|
430
|
+
const res = await fetch(`${this._gitlabBaseUrl}/api/v4/groups?min_access_level=10&per_page=100&page=${page}`, {
|
|
431
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
432
|
+
});
|
|
433
|
+
if (!res.ok)
|
|
434
|
+
break;
|
|
435
|
+
const groups = (await res.json());
|
|
436
|
+
if (groups.length === 0)
|
|
437
|
+
break;
|
|
438
|
+
const matched = groups.some((g) => {
|
|
439
|
+
const fp = g.full_path.toLowerCase();
|
|
440
|
+
return allowedPaths.some((allowed) => fp === allowed || fp.startsWith(`${allowed}/`));
|
|
441
|
+
});
|
|
442
|
+
if (matched)
|
|
443
|
+
return true;
|
|
444
|
+
const totalPages = Number.parseInt(res.headers.get("x-total-pages") ?? "1", 10);
|
|
445
|
+
if (page >= totalPages)
|
|
446
|
+
break;
|
|
447
|
+
page++;
|
|
448
|
+
}
|
|
449
|
+
return false;
|
|
402
450
|
}
|
|
403
451
|
// ---- Refresh token -----------------------------------------------------
|
|
404
452
|
async exchangeRefreshToken(_client, refreshToken, scopes, resource) {
|
|
@@ -598,6 +646,6 @@ class GitLabOAuthServerProvider {
|
|
|
598
646
|
* callback-proxy state is encoded into opaque OAuth values
|
|
599
647
|
* instead of an in-memory cache, enabling multi-pod deploys.
|
|
600
648
|
*/
|
|
601
|
-
export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, callbackProxyEnabled = false, callbackUrl = "", stateless = null) {
|
|
602
|
-
return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled, callbackUrl, stateless);
|
|
649
|
+
export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes, allowedGroups, callbackProxyEnabled = false, callbackUrl = "", stateless = null) {
|
|
650
|
+
return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, allowedGroups, callbackProxyEnabled, callbackUrl, stateless);
|
|
603
651
|
}
|
package/build/schemas.js
CHANGED
|
@@ -1515,6 +1515,79 @@ export const ListBranchesSchema = ProjectParamsSchema.extend({
|
|
|
1515
1515
|
export const DeleteBranchSchema = ProjectParamsSchema.extend({
|
|
1516
1516
|
branch_name: z.string().describe("Name of the branch to delete"),
|
|
1517
1517
|
});
|
|
1518
|
+
// Protected Branches related schemas
|
|
1519
|
+
export const ListProtectedBranchesSchema = ProjectParamsSchema.extend({
|
|
1520
|
+
search: z.string().optional().describe("Search term to filter protected branches by name"),
|
|
1521
|
+
}).merge(PaginationOptionsSchema);
|
|
1522
|
+
export const GetProtectedBranchSchema = ProjectParamsSchema.extend({
|
|
1523
|
+
branch_name: z.string().describe("Name of the protected branch"),
|
|
1524
|
+
});
|
|
1525
|
+
// String-aware boolean preprocessing: correctly handles "false" → false
|
|
1526
|
+
const stringBoolean = z.preprocess((val) => {
|
|
1527
|
+
if (typeof val === "string") {
|
|
1528
|
+
const lower = val.toLowerCase();
|
|
1529
|
+
if (lower === "false" || lower === "0")
|
|
1530
|
+
return false;
|
|
1531
|
+
if (lower === "true" || lower === "1")
|
|
1532
|
+
return true;
|
|
1533
|
+
}
|
|
1534
|
+
return val;
|
|
1535
|
+
}, z.boolean().optional());
|
|
1536
|
+
const protectedBranchAccessLevel = z.coerce
|
|
1537
|
+
.number()
|
|
1538
|
+
.int()
|
|
1539
|
+
.refine((level) => [0, 30, 40, 60].includes(level), {
|
|
1540
|
+
message: "Access level must be one of 0 (No access), 30 (Developer), 40 (Maintainer), or 60 (Admin)",
|
|
1541
|
+
});
|
|
1542
|
+
export const ProtectBranchSchema = z.preprocess((input) => {
|
|
1543
|
+
if (typeof input !== "object" || input === null) {
|
|
1544
|
+
return input;
|
|
1545
|
+
}
|
|
1546
|
+
const args = { ...input };
|
|
1547
|
+
if (!args.branch_name && args.name) {
|
|
1548
|
+
args.branch_name = args.name;
|
|
1549
|
+
}
|
|
1550
|
+
return args;
|
|
1551
|
+
}, ProjectParamsSchema.extend({
|
|
1552
|
+
branch_name: z.string().describe("Branch name or wildcard pattern to protect"),
|
|
1553
|
+
name: z
|
|
1554
|
+
.string()
|
|
1555
|
+
.optional()
|
|
1556
|
+
.describe("Deprecated alias for branch_name; prefer branch_name for consistency"),
|
|
1557
|
+
push_access_level: protectedBranchAccessLevel
|
|
1558
|
+
.optional()
|
|
1559
|
+
.describe("Access level for pushing (0=No access, 30=Developer, 40=Maintainer, 60=Admin). GitLab default applies when omitted."),
|
|
1560
|
+
merge_access_level: protectedBranchAccessLevel
|
|
1561
|
+
.optional()
|
|
1562
|
+
.describe("Access level for merging (0=No access, 30=Developer, 40=Maintainer, 60=Admin). GitLab default applies when omitted."),
|
|
1563
|
+
unprotect_access_level: protectedBranchAccessLevel
|
|
1564
|
+
.optional()
|
|
1565
|
+
.describe("Access level for unprotecting (0=No access, 30=Developer, 40=Maintainer, 60=Admin). GitLab default applies when omitted."),
|
|
1566
|
+
allow_force_push: stringBoolean.describe("Allow force push to the protected branch. Default: false"),
|
|
1567
|
+
code_owner_approval_required: stringBoolean.describe("Require code owner approval before merging (PREMIUM). Default: false"),
|
|
1568
|
+
}));
|
|
1569
|
+
export const UnprotectBranchSchema = ProjectParamsSchema.extend({
|
|
1570
|
+
branch_name: z.string().describe("Name of the protected branch to unprotect"),
|
|
1571
|
+
});
|
|
1572
|
+
// Update default branch schema
|
|
1573
|
+
export const UpdateDefaultBranchSchema = ProjectParamsSchema.extend({
|
|
1574
|
+
default_branch: z.string().describe("The new default branch name for the project"),
|
|
1575
|
+
});
|
|
1576
|
+
export const GitLabProtectedBranchAccessLevelSchema = z.object({
|
|
1577
|
+
access_level: z.number().nullable().optional(),
|
|
1578
|
+
access_level_description: z.string().optional(),
|
|
1579
|
+
user_id: z.number().optional(),
|
|
1580
|
+
group_id: z.number().optional(),
|
|
1581
|
+
});
|
|
1582
|
+
export const GitLabProtectedBranchSchema = z.object({
|
|
1583
|
+
id: z.number().optional(),
|
|
1584
|
+
name: z.string(),
|
|
1585
|
+
push_access_levels: z.array(GitLabProtectedBranchAccessLevelSchema).optional(),
|
|
1586
|
+
merge_access_levels: z.array(GitLabProtectedBranchAccessLevelSchema).optional(),
|
|
1587
|
+
unprotect_access_levels: z.array(GitLabProtectedBranchAccessLevelSchema).optional(),
|
|
1588
|
+
allow_force_push: z.boolean().optional(),
|
|
1589
|
+
code_owner_approval_required: z.boolean().optional(),
|
|
1590
|
+
});
|
|
1518
1591
|
export const GitLabBranchSchema = z.object({
|
|
1519
1592
|
name: z.string(),
|
|
1520
1593
|
commit: z.object({
|
|
@@ -79,7 +79,6 @@ function addOAuthEndpoints(mockGitLab, validToken, clientId, baseUrl) {
|
|
|
79
79
|
// Test suite: Discovery endpoints
|
|
80
80
|
// ---------------------------------------------------------------------------
|
|
81
81
|
describe("MCP OAuth — Discovery Endpoints", () => {
|
|
82
|
-
let mcpUrl;
|
|
83
82
|
let mcpBaseUrl;
|
|
84
83
|
let mockGitLab;
|
|
85
84
|
let servers = [];
|
|
@@ -94,7 +93,6 @@ describe("MCP OAuth — Discovery Endpoints", () => {
|
|
|
94
93
|
addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
|
|
95
94
|
const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE);
|
|
96
95
|
mcpBaseUrl = `http://${HOST}:${mcpPort}`;
|
|
97
|
-
mcpUrl = `${mcpBaseUrl}/mcp`;
|
|
98
96
|
const server = await launchServer({
|
|
99
97
|
mode: TransportMode.STREAMABLE_HTTP,
|
|
100
98
|
port: mcpPort,
|
|
@@ -285,13 +283,6 @@ describe("MCP OAuth — /mcp Auth Enforcement", () => {
|
|
|
285
283
|
describe("MCP OAuth — BoundedClientCache", () => {
|
|
286
284
|
// Access the internal class via a minimal provider (it's not exported directly)
|
|
287
285
|
// by driving it through the public clientsStore API.
|
|
288
|
-
function makeClient(id, redirectUri = "https://example.com/cb") {
|
|
289
|
-
return {
|
|
290
|
-
client_id: id,
|
|
291
|
-
redirect_uris: [redirectUri],
|
|
292
|
-
token_endpoint_auth_method: "none",
|
|
293
|
-
};
|
|
294
|
-
}
|
|
295
286
|
async function buildCachingProvider() {
|
|
296
287
|
// Spin up a DCR stub that returns a stable client_id from the request body.
|
|
297
288
|
// The stub reads client_id from the incoming body (set by the SDK's register
|
|
@@ -380,7 +371,7 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => {
|
|
|
380
371
|
test("verifyAccessToken throws on non-OK response", async () => {
|
|
381
372
|
// Spin up a tiny local server that always returns 401
|
|
382
373
|
const { createServer } = await import("node:http");
|
|
383
|
-
const stub = createServer((
|
|
374
|
+
const stub = createServer((_req, res) => {
|
|
384
375
|
res.writeHead(401);
|
|
385
376
|
res.end(JSON.stringify({ error: "invalid_token" }));
|
|
386
377
|
});
|
|
@@ -599,3 +590,195 @@ describe("MCP OAuth — Header Auth Fallback", () => {
|
|
|
599
590
|
console.log(" ✓ No auth still returns 401");
|
|
600
591
|
});
|
|
601
592
|
});
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
// Test suite: Group Allowlist (unit tests — exchangeAuthorizationCode)
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
describe("MCP OAuth — Group Allowlist", () => {
|
|
597
|
+
let stubServer;
|
|
598
|
+
let stubUrl;
|
|
599
|
+
// Mutable state lets each test control the stub's response without
|
|
600
|
+
// spinning up a new server.
|
|
601
|
+
let groupsForPage = () => [];
|
|
602
|
+
let totalPages = 1;
|
|
603
|
+
before(async () => {
|
|
604
|
+
const { createServer } = await import("node:http");
|
|
605
|
+
stubServer = createServer((req, res) => {
|
|
606
|
+
const url = new URL(req.url, "http://127.0.0.1");
|
|
607
|
+
res.setHeader("Content-Type", "application/json");
|
|
608
|
+
// Token exchange — passthrough mode calls POST /oauth/token
|
|
609
|
+
if (req.method === "POST" && url.pathname === "/oauth/token") {
|
|
610
|
+
res.writeHead(200).end(JSON.stringify({
|
|
611
|
+
access_token: MOCK_OAUTH_TOKEN,
|
|
612
|
+
token_type: "bearer",
|
|
613
|
+
expires_in: 7200,
|
|
614
|
+
refresh_token: "mock-refresh",
|
|
615
|
+
scope: "api",
|
|
616
|
+
}));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (req.method === "GET" && url.pathname === "/api/v4/groups") {
|
|
620
|
+
const page = Number.parseInt(url.searchParams.get("page") ?? "1", 10);
|
|
621
|
+
res
|
|
622
|
+
.writeHead(200, { "x-total-pages": String(totalPages) })
|
|
623
|
+
.end(JSON.stringify(groupsForPage(page)));
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
res.writeHead(404).end("{}");
|
|
627
|
+
});
|
|
628
|
+
await new Promise(resolve => stubServer.listen(0, "127.0.0.1", resolve));
|
|
629
|
+
const addr = stubServer.address();
|
|
630
|
+
stubUrl = `http://127.0.0.1:${addr.port}`;
|
|
631
|
+
});
|
|
632
|
+
after(() => {
|
|
633
|
+
stubServer.close();
|
|
634
|
+
});
|
|
635
|
+
async function makeProvider(allowedGroups = undefined, callbackProxyEnabled = false) {
|
|
636
|
+
const { createGitLabOAuthProvider } = await import("../oauth-proxy.js");
|
|
637
|
+
return createGitLabOAuthProvider(stubUrl, "test-app-id", "GitLab MCP Server", false, undefined, // customScopes
|
|
638
|
+
allowedGroups, callbackProxyEnabled, callbackProxyEnabled ? "https://mcp.example.test/callback" : "" // callbackUrl
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
function exchange(provider) {
|
|
642
|
+
return provider.exchangeAuthorizationCode({}, "mock-auth-code");
|
|
643
|
+
}
|
|
644
|
+
test("no allowedGroups — groups API not called, tokens returned", async () => {
|
|
645
|
+
const provider = await makeProvider(undefined);
|
|
646
|
+
groupsForPage = () => []; // wrong groups — proves endpoint isn't called
|
|
647
|
+
totalPages = 1;
|
|
648
|
+
const tokens = await exchange(provider);
|
|
649
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
650
|
+
console.log(" ✓ No allowedGroups: token issued without group check");
|
|
651
|
+
});
|
|
652
|
+
test("user is direct member of the allowed group → token issued", async () => {
|
|
653
|
+
const provider = await makeProvider(["my-org"]);
|
|
654
|
+
groupsForPage = () => [{ full_path: "my-org" }];
|
|
655
|
+
totalPages = 1;
|
|
656
|
+
const tokens = await exchange(provider);
|
|
657
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
658
|
+
console.log(" ✓ Direct group member: token issued");
|
|
659
|
+
});
|
|
660
|
+
test("user in first-level subgroup of allowed group → token issued", async () => {
|
|
661
|
+
const provider = await makeProvider(["my-org"]);
|
|
662
|
+
groupsForPage = () => [{ full_path: "my-org/team-a" }];
|
|
663
|
+
totalPages = 1;
|
|
664
|
+
const tokens = await exchange(provider);
|
|
665
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
666
|
+
console.log(" ✓ First-level subgroup member: token issued");
|
|
667
|
+
});
|
|
668
|
+
test("user in nested subgroup → token issued", async () => {
|
|
669
|
+
const provider = await makeProvider(["my-org"]);
|
|
670
|
+
groupsForPage = () => [{ full_path: "my-org/team-a/squad-1" }];
|
|
671
|
+
totalPages = 1;
|
|
672
|
+
const tokens = await exchange(provider);
|
|
673
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
674
|
+
console.log(" ✓ Nested subgroup member: token issued");
|
|
675
|
+
});
|
|
676
|
+
test("user not in any matching group → token issuance denied", async () => {
|
|
677
|
+
const provider = await makeProvider(["my-org"]);
|
|
678
|
+
groupsForPage = () => [{ full_path: "other-org" }];
|
|
679
|
+
totalPages = 1;
|
|
680
|
+
await assert.rejects(() => exchange(provider), (err) => {
|
|
681
|
+
assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
|
|
682
|
+
return true;
|
|
683
|
+
});
|
|
684
|
+
console.log(" ✓ Non-member: token issuance denied");
|
|
685
|
+
});
|
|
686
|
+
test("prefix spoofing rejected — my-org2 does not match my-org", async () => {
|
|
687
|
+
const provider = await makeProvider(["my-org"]);
|
|
688
|
+
groupsForPage = () => [{ full_path: "my-org2" }];
|
|
689
|
+
totalPages = 1;
|
|
690
|
+
await assert.rejects(() => exchange(provider), (err) => {
|
|
691
|
+
assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
|
|
692
|
+
return true;
|
|
693
|
+
});
|
|
694
|
+
console.log(" ✓ my-org2 correctly rejected (not a sub-path of my-org)");
|
|
695
|
+
});
|
|
696
|
+
test("user matches second of multiple allowed groups → token issued", async () => {
|
|
697
|
+
const provider = await makeProvider(["team-x", "my-org"]);
|
|
698
|
+
groupsForPage = () => [{ full_path: "my-org/backend" }];
|
|
699
|
+
totalPages = 1;
|
|
700
|
+
const tokens = await exchange(provider);
|
|
701
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
702
|
+
console.log(" ✓ Match on second allowed group: token issued");
|
|
703
|
+
});
|
|
704
|
+
test("match found on page 2 of paginated groups response → token issued", async () => {
|
|
705
|
+
const provider = await makeProvider(["my-org"]);
|
|
706
|
+
totalPages = 2;
|
|
707
|
+
groupsForPage = page => page === 1 ? [{ full_path: "other-org" }] : [{ full_path: "my-org/team-b" }];
|
|
708
|
+
const tokens = await exchange(provider);
|
|
709
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
710
|
+
console.log(" ✓ Group found on page 2: token issued");
|
|
711
|
+
});
|
|
712
|
+
test("user not in group across all pages → token issuance denied", async () => {
|
|
713
|
+
const provider = await makeProvider(["my-org"]);
|
|
714
|
+
totalPages = 2;
|
|
715
|
+
groupsForPage = () => [{ full_path: "other-org" }];
|
|
716
|
+
await assert.rejects(() => exchange(provider), (err) => {
|
|
717
|
+
assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
|
|
718
|
+
return true;
|
|
719
|
+
});
|
|
720
|
+
console.log(" ✓ Non-member across all pages: token issuance denied");
|
|
721
|
+
});
|
|
722
|
+
test("callback-proxy exchange also enforces allowed groups", async () => {
|
|
723
|
+
const provider = await makeProvider(["my-org"], true);
|
|
724
|
+
groupsForPage = () => [{ full_path: "my-org/team-a" }];
|
|
725
|
+
totalPages = 1;
|
|
726
|
+
const proxyCode = "proxy-code-for-group-test";
|
|
727
|
+
const clientId = "test-client";
|
|
728
|
+
const redirectUri = "https://client.example.test/callback";
|
|
729
|
+
provider._storedTokens.set(proxyCode, {
|
|
730
|
+
tokens: {
|
|
731
|
+
access_token: MOCK_OAUTH_TOKEN,
|
|
732
|
+
token_type: "bearer",
|
|
733
|
+
expires_in: 7200,
|
|
734
|
+
refresh_token: "mock-refresh",
|
|
735
|
+
scope: "api",
|
|
736
|
+
},
|
|
737
|
+
clientId,
|
|
738
|
+
clientCodeChallenge: "",
|
|
739
|
+
clientRedirectUri: redirectUri,
|
|
740
|
+
createdAt: Date.now(),
|
|
741
|
+
});
|
|
742
|
+
const tokens = await provider.exchangeAuthorizationCode({
|
|
743
|
+
client_id: clientId,
|
|
744
|
+
}, proxyCode, undefined, redirectUri);
|
|
745
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
746
|
+
console.log(" ✓ Callback-proxy exchange enforces allowedGroups before issuing tokens");
|
|
747
|
+
});
|
|
748
|
+
test("callback-proxy exchange denies users outside allowed groups", async () => {
|
|
749
|
+
const provider = await makeProvider(["my-org"], true);
|
|
750
|
+
groupsForPage = () => [{ full_path: "other-org/team-a" }];
|
|
751
|
+
totalPages = 1;
|
|
752
|
+
const proxyCode = "proxy-code-for-group-deny-test";
|
|
753
|
+
const clientId = "test-client-deny";
|
|
754
|
+
const redirectUri = "https://client.example.test/callback";
|
|
755
|
+
provider._storedTokens.set(proxyCode, {
|
|
756
|
+
tokens: {
|
|
757
|
+
access_token: MOCK_OAUTH_TOKEN,
|
|
758
|
+
token_type: "bearer",
|
|
759
|
+
expires_in: 7200,
|
|
760
|
+
refresh_token: "mock-refresh",
|
|
761
|
+
scope: "api",
|
|
762
|
+
},
|
|
763
|
+
clientId,
|
|
764
|
+
clientCodeChallenge: "",
|
|
765
|
+
clientRedirectUri: redirectUri,
|
|
766
|
+
createdAt: Date.now(),
|
|
767
|
+
});
|
|
768
|
+
await assert.rejects(() => provider.exchangeAuthorizationCode({
|
|
769
|
+
client_id: clientId,
|
|
770
|
+
}, proxyCode, undefined, redirectUri), (err) => {
|
|
771
|
+
assert.ok(err.message.includes("Access denied"), `Unexpected message: ${err.message}`);
|
|
772
|
+
return true;
|
|
773
|
+
});
|
|
774
|
+
console.log(" ✓ Callback-proxy exchange denies users outside allowedGroups");
|
|
775
|
+
});
|
|
776
|
+
test("matching is case-insensitive", async () => {
|
|
777
|
+
const provider = await makeProvider(["My-Org"]);
|
|
778
|
+
groupsForPage = () => [{ full_path: "my-org/team-a" }];
|
|
779
|
+
totalPages = 1;
|
|
780
|
+
const tokens = await exchange(provider);
|
|
781
|
+
assert.strictEqual(tokens.access_token, MOCK_OAUTH_TOKEN);
|
|
782
|
+
console.log(" ✓ Case-insensitive match: token issued");
|
|
783
|
+
});
|
|
784
|
+
});
|
|
@@ -10,7 +10,6 @@ import { CustomHeaderClient } from './clients/custom-header-client.js';
|
|
|
10
10
|
// Test constants
|
|
11
11
|
const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
12
12
|
const MOCK_JOB_TOKEN = 'glcbt-mock-job-token-9876';
|
|
13
|
-
const TEST_PROJECT_ID = '123';
|
|
14
13
|
// Port ranges to avoid collisions
|
|
15
14
|
const MOCK_GITLAB_PORT_BASE = 9000;
|
|
16
15
|
const MOCK_GITLAB_PORT_OFFSET = 500; // Offset for timeout test suite
|
|
@@ -22,6 +21,26 @@ const TIMEOUT_BUFFER_MS = 1000; // Extra time beyond timeout to ensure expiratio
|
|
|
22
21
|
const TIMEOUT_TEST_WAIT_MS = SESSION_TIMEOUT_SECONDS * 1000 + TIMEOUT_BUFFER_MS;
|
|
23
22
|
const KEEPALIVE_INTERVAL_MS = 2000; // Must be less than SESSION_TIMEOUT_SECONDS
|
|
24
23
|
const KEEPALIVE_REQUEST_COUNT = 3; // Number of keepalive requests to test
|
|
24
|
+
async function getMetrics(mcpUrl) {
|
|
25
|
+
const metricsUrl = mcpUrl.replace(/\/mcp$/, '/metrics');
|
|
26
|
+
const response = await fetch(metricsUrl);
|
|
27
|
+
assert.strictEqual(response.status, 200, 'metrics endpoint should be available');
|
|
28
|
+
return (await response.json());
|
|
29
|
+
}
|
|
30
|
+
async function waitForSessionDecrease(mcpUrl, beforeTimeout, timeoutMs = 3000) {
|
|
31
|
+
const startedAt = Date.now();
|
|
32
|
+
let last = await getMetrics(mcpUrl);
|
|
33
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
34
|
+
if (last.activeSessions < beforeTimeout.activeSessions &&
|
|
35
|
+
last.authenticatedSessions < beforeTimeout.authenticatedSessions) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
39
|
+
last = await getMetrics(mcpUrl);
|
|
40
|
+
}
|
|
41
|
+
assert.ok(last.activeSessions < beforeTimeout.activeSessions, `activeSessions should decrease after timeout (${last.activeSessions} !< ${beforeTimeout.activeSessions})`);
|
|
42
|
+
assert.ok(last.authenticatedSessions < beforeTimeout.authenticatedSessions, `authenticatedSessions should decrease after timeout (${last.authenticatedSessions} !< ${beforeTimeout.authenticatedSessions})`);
|
|
43
|
+
}
|
|
25
44
|
console.log('🔐 Remote Authorization Test Suite');
|
|
26
45
|
console.log('');
|
|
27
46
|
describe('Remote Authorization - Basic Functionality', () => {
|
|
@@ -178,6 +197,7 @@ describe('Remote Authorization - Session Timeout', () => {
|
|
|
178
197
|
test('session timeout expiration - inactivity expires auth', async () => {
|
|
179
198
|
// Add a small delay to ensure server is ready/clean from previous test
|
|
180
199
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
200
|
+
const baseline = await getMetrics(mcpUrl);
|
|
181
201
|
// Step 1: Connect WITH auth header to establish session
|
|
182
202
|
const clientWithAuth = new CustomHeaderClient({
|
|
183
203
|
'authorization': `Bearer ${MOCK_TOKEN}`
|
|
@@ -190,9 +210,14 @@ describe('Remote Authorization - Session Timeout', () => {
|
|
|
190
210
|
const sessionId = clientWithAuth.getSessionId();
|
|
191
211
|
assert.ok(sessionId, 'Session ID should exist');
|
|
192
212
|
console.log(` ℹ️ Session ID: ${sessionId}`);
|
|
213
|
+
const beforeTimeout = await getMetrics(mcpUrl);
|
|
214
|
+
assert.strictEqual(beforeTimeout.activeSessions, baseline.activeSessions + 1, 'Session should occupy one additional active slot');
|
|
215
|
+
assert.strictEqual(beforeTimeout.authenticatedSessions, baseline.authenticatedSessions + 1, 'Session should add one authenticated session');
|
|
193
216
|
// Step 2: Wait for timeout WITHOUT making any requests
|
|
194
217
|
console.log(` ⏳ Waiting ${TIMEOUT_TEST_WAIT_MS / 1000}s for timeout without activity...`);
|
|
195
218
|
await new Promise(resolve => setTimeout(resolve, TIMEOUT_TEST_WAIT_MS));
|
|
219
|
+
await waitForSessionDecrease(mcpUrl, beforeTimeout);
|
|
220
|
+
console.log(' ✓ Timeout closed transport and released active session slot');
|
|
196
221
|
// Step 3: Try to make request WITHOUT auth header - should fail with 401
|
|
197
222
|
try {
|
|
198
223
|
const response = await fetch(mcpUrl, {
|
|
@@ -34,7 +34,7 @@ function loadMaterial(current, previous) {
|
|
|
34
34
|
return m;
|
|
35
35
|
}
|
|
36
36
|
function makeProvider(material, { callbackProxy = true } = {}) {
|
|
37
|
-
return createGitLabOAuthProvider(GITLAB_BASE, "real-gitlab-app-id", "Test Server", false, undefined, callbackProxy, callbackProxy ? CALLBACK_URL : "", material
|
|
37
|
+
return createGitLabOAuthProvider(GITLAB_BASE, "real-gitlab-app-id", "Test Server", false, undefined, undefined, callbackProxy, callbackProxy ? CALLBACK_URL : "", material
|
|
38
38
|
? {
|
|
39
39
|
material,
|
|
40
40
|
clientTtlSeconds: 86400,
|
|
@@ -31,6 +31,7 @@ function loadMaterial(current, previous) {
|
|
|
31
31
|
function makeProvider(material, { callbackProxy = false } = {}) {
|
|
32
32
|
return createGitLabOAuthProvider("https://gitlab.example.com", "real-gitlab-app-id", "GitLab MCP Server (test)", false, // readOnly
|
|
33
33
|
undefined, // customScopes
|
|
34
|
+
undefined, // allowedGroups
|
|
34
35
|
callbackProxy, callbackProxy ? "https://mcp.example.com/callback" : "", material
|
|
35
36
|
? {
|
|
36
37
|
material,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { after, before, describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
5
|
+
const MOCK_TOKEN = "glpat-mock-token-protected-branches";
|
|
6
|
+
const TEST_PROJECT_ID = "123";
|
|
7
|
+
const TEST_BRANCH = "main";
|
|
8
|
+
function buildProtectedBranch(overrides = {}) {
|
|
9
|
+
return {
|
|
10
|
+
id: 1,
|
|
11
|
+
name: TEST_BRANCH,
|
|
12
|
+
push_access_levels: [
|
|
13
|
+
{ access_level: 30, access_level_description: "Developers + Maintainers" },
|
|
14
|
+
],
|
|
15
|
+
merge_access_levels: [{ access_level: 40, access_level_description: "Maintainers" }],
|
|
16
|
+
unprotect_access_levels: [{ access_level: 60, access_level_description: "Administrators" }],
|
|
17
|
+
allow_force_push: false,
|
|
18
|
+
code_owner_approval_required: false,
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function callTool(toolName, args, env) {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const proc = spawn("node", ["build/index.js"], {
|
|
25
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
26
|
+
env: {
|
|
27
|
+
...process.env,
|
|
28
|
+
...env,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
let output = "";
|
|
32
|
+
let errorOutput = "";
|
|
33
|
+
proc.stdout?.on("data", (d) => (output += d));
|
|
34
|
+
proc.stderr?.on("data", (d) => (errorOutput += d));
|
|
35
|
+
proc.on("close", code => {
|
|
36
|
+
if (code !== 0) {
|
|
37
|
+
reject(new Error(`Process exited with code ${code}: ${errorOutput}`));
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const line = output.split("\n").find(l => l.startsWith("{"));
|
|
41
|
+
if (!line) {
|
|
42
|
+
reject(new Error("No JSON output found"));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const response = JSON.parse(line);
|
|
46
|
+
if (response.error) {
|
|
47
|
+
reject(new Error(response.error.message ?? JSON.stringify(response.error)));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const content = response.result?.content?.[0]?.text;
|
|
51
|
+
if (!content) {
|
|
52
|
+
resolve(response.result);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
resolve(JSON.parse(content));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
resolve(content);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
proc.stdin?.end(JSON.stringify({
|
|
63
|
+
jsonrpc: "2.0",
|
|
64
|
+
id: 1,
|
|
65
|
+
method: "tools/call",
|
|
66
|
+
params: { name: toolName, arguments: args },
|
|
67
|
+
}) + "\n");
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
describe("protected branch tools", () => {
|
|
71
|
+
let mockGitLab;
|
|
72
|
+
let mockGitLabUrl;
|
|
73
|
+
before(async () => {
|
|
74
|
+
const mockPort = await findMockServerPort(20500, 50);
|
|
75
|
+
mockGitLab = new MockGitLabServer({
|
|
76
|
+
port: mockPort,
|
|
77
|
+
validTokens: [MOCK_TOKEN],
|
|
78
|
+
});
|
|
79
|
+
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/protected_branches`, (req, res) => {
|
|
80
|
+
assert.strictEqual(req.query.search, "main");
|
|
81
|
+
assert.strictEqual(req.query.page, "2");
|
|
82
|
+
assert.strictEqual(req.query.per_page, "10");
|
|
83
|
+
res.json([buildProtectedBranch()]);
|
|
84
|
+
});
|
|
85
|
+
mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/protected_branches/${TEST_BRANCH}`, (_req, res) => {
|
|
86
|
+
res.json(buildProtectedBranch());
|
|
87
|
+
});
|
|
88
|
+
mockGitLab.addMockHandler("post", `/projects/${TEST_PROJECT_ID}/protected_branches`, (req, res) => {
|
|
89
|
+
assert.deepStrictEqual(req.body, {
|
|
90
|
+
name: TEST_BRANCH,
|
|
91
|
+
push_access_level: 30,
|
|
92
|
+
merge_access_level: 40,
|
|
93
|
+
unprotect_access_level: 60,
|
|
94
|
+
allow_force_push: false,
|
|
95
|
+
code_owner_approval_required: false,
|
|
96
|
+
});
|
|
97
|
+
res.status(201).json(buildProtectedBranch());
|
|
98
|
+
});
|
|
99
|
+
mockGitLab.addMockHandler("delete", `/projects/${TEST_PROJECT_ID}/protected_branches/${TEST_BRANCH}`, (_req, res) => {
|
|
100
|
+
res.status(204).send();
|
|
101
|
+
});
|
|
102
|
+
mockGitLab.addMockHandler("put", `/projects/${TEST_PROJECT_ID}`, (req, res) => {
|
|
103
|
+
assert.deepStrictEqual(req.body, { default_branch: TEST_BRANCH });
|
|
104
|
+
res.json({ id: Number(TEST_PROJECT_ID), default_branch: TEST_BRANCH });
|
|
105
|
+
});
|
|
106
|
+
await mockGitLab.start();
|
|
107
|
+
mockGitLabUrl = mockGitLab.getUrl();
|
|
108
|
+
});
|
|
109
|
+
after(async () => {
|
|
110
|
+
await mockGitLab.stop();
|
|
111
|
+
});
|
|
112
|
+
const env = () => ({
|
|
113
|
+
GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
|
|
114
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
|
|
115
|
+
GITLAB_TOOLSETS: "branches",
|
|
116
|
+
});
|
|
117
|
+
test("list_protected_branches forwards search and pagination", async () => {
|
|
118
|
+
const result = await callTool("list_protected_branches", { project_id: TEST_PROJECT_ID, search: "main", page: 2, per_page: 10 }, env());
|
|
119
|
+
assert.ok(Array.isArray(result));
|
|
120
|
+
assert.strictEqual(result[0].name, TEST_BRANCH);
|
|
121
|
+
});
|
|
122
|
+
test("get_protected_branch parses a single protected branch", async () => {
|
|
123
|
+
const result = await callTool("get_protected_branch", { project_id: TEST_PROJECT_ID, branch_name: TEST_BRANCH }, env());
|
|
124
|
+
assert.strictEqual(result.name, TEST_BRANCH);
|
|
125
|
+
assert.strictEqual(result.allow_force_push, false);
|
|
126
|
+
});
|
|
127
|
+
test("protect_branch posts normalized branch name, access levels, and false booleans", async () => {
|
|
128
|
+
const result = await callTool("protect_branch", {
|
|
129
|
+
project_id: TEST_PROJECT_ID,
|
|
130
|
+
branch_name: TEST_BRANCH,
|
|
131
|
+
push_access_level: "30",
|
|
132
|
+
merge_access_level: 40,
|
|
133
|
+
unprotect_access_level: 60,
|
|
134
|
+
allow_force_push: "false",
|
|
135
|
+
code_owner_approval_required: "false",
|
|
136
|
+
}, env());
|
|
137
|
+
assert.strictEqual(result.name, TEST_BRANCH);
|
|
138
|
+
});
|
|
139
|
+
test("protect_branch rejects invalid access levels before calling GitLab", async () => {
|
|
140
|
+
await assert.rejects(() => callTool("protect_branch", {
|
|
141
|
+
project_id: TEST_PROJECT_ID,
|
|
142
|
+
branch_name: TEST_BRANCH,
|
|
143
|
+
push_access_level: 99,
|
|
144
|
+
}, env()), /Access level must be one of/);
|
|
145
|
+
});
|
|
146
|
+
test("unprotect_branch sends DELETE and returns status", async () => {
|
|
147
|
+
const result = await callTool("unprotect_branch", { project_id: TEST_PROJECT_ID, branch_name: TEST_BRANCH }, env());
|
|
148
|
+
assert.deepStrictEqual(result, { status: "unprotected", branch: TEST_BRANCH });
|
|
149
|
+
});
|
|
150
|
+
test("update_default_branch sends the expected PUT body", async () => {
|
|
151
|
+
const result = await callTool("update_default_branch", { project_id: TEST_PROJECT_ID, default_branch: TEST_BRANCH }, env());
|
|
152
|
+
assert.strictEqual(result.status, "updated");
|
|
153
|
+
assert.strictEqual(result.default_branch, TEST_BRANCH);
|
|
154
|
+
});
|
|
155
|
+
});
|
package/build/tools/registry.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
2
2
|
import { toJSONSchema } from "../utils/schema.js";
|
|
3
3
|
import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, SSE, STREAMABLE_HTTP, } from "../config.js";
|
|
4
|
-
import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, } from "../schemas.js";
|
|
4
|
+
import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, MarkAllTodosDoneSchema, ListTodosSchema, MarkTodoDoneSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateCommitStatusSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTagSchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteBranchSchema, GetProtectedBranchSchema, ListProtectedBranchesSchema, ProtectBranchSchema, UnprotectBranchSchema, UpdateDefaultBranchSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteTagSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadAttachmentRemoteSchema, DownloadJobArtifactsSchema, DownloadJobArtifactsRemoteSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, HealthCheckSchema, GetBranchSchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetFileBlameSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTagSchema, GetTagSignatureSchema, GetTimelineEventsSchema, GetUsersSchema, GetUserSchema, WhoAmISchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListBranchesSchema, ListCommitsSchema, ListCommitStatusesSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListTagsSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, } from "../schemas.js";
|
|
5
5
|
const IS_REMOTE = SSE || STREAMABLE_HTTP;
|
|
6
6
|
// Define all available tools
|
|
7
7
|
export const allTools = [
|
|
@@ -105,6 +105,31 @@ export const allTools = [
|
|
|
105
105
|
description: "Delete branch from project",
|
|
106
106
|
inputSchema: toJSONSchema(DeleteBranchSchema),
|
|
107
107
|
},
|
|
108
|
+
{
|
|
109
|
+
name: "list_protected_branches",
|
|
110
|
+
description: "List protected branches in a project, supports search filter",
|
|
111
|
+
inputSchema: toJSONSchema(ListProtectedBranchesSchema),
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "get_protected_branch",
|
|
115
|
+
description: "Get details of a single protected branch (access levels, force push settings)",
|
|
116
|
+
inputSchema: toJSONSchema(GetProtectedBranchSchema),
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "protect_branch",
|
|
120
|
+
description: "Protect a repository branch (set push/merge/unprotect access levels)",
|
|
121
|
+
inputSchema: toJSONSchema(ProtectBranchSchema),
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "unprotect_branch",
|
|
125
|
+
description: "Remove protection from a previously protected branch",
|
|
126
|
+
inputSchema: toJSONSchema(UnprotectBranchSchema),
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "update_default_branch",
|
|
130
|
+
description: "Change the default branch of a project",
|
|
131
|
+
inputSchema: toJSONSchema(UpdateDefaultBranchSchema),
|
|
132
|
+
},
|
|
108
133
|
{
|
|
109
134
|
name: "get_merge_request",
|
|
110
135
|
description: "Get details of a merge request (mergeRequestIid or branchName required)",
|
|
@@ -1038,6 +1063,8 @@ export const readOnlyTools = new Set([
|
|
|
1038
1063
|
"get_branch",
|
|
1039
1064
|
"list_branches",
|
|
1040
1065
|
"get_branch_diffs",
|
|
1066
|
+
"list_protected_branches",
|
|
1067
|
+
"get_protected_branch",
|
|
1041
1068
|
"list_merge_request_pipelines",
|
|
1042
1069
|
"get_merge_request_note",
|
|
1043
1070
|
"get_merge_request_notes",
|
|
@@ -1149,6 +1176,9 @@ export const destructiveTools = new Set([
|
|
|
1149
1176
|
"delete_work_item_emoji_reaction",
|
|
1150
1177
|
"delete_work_item_note_emoji_reaction",
|
|
1151
1178
|
"delete_branch",
|
|
1179
|
+
"unprotect_branch",
|
|
1180
|
+
"protect_branch",
|
|
1181
|
+
"update_default_branch",
|
|
1152
1182
|
"merge_merge_request",
|
|
1153
1183
|
"push_files",
|
|
1154
1184
|
"delete_project_variable",
|
|
@@ -1306,6 +1336,11 @@ export const TOOLSET_DEFINITIONS = [
|
|
|
1306
1336
|
"get_branch",
|
|
1307
1337
|
"list_branches",
|
|
1308
1338
|
"delete_branch",
|
|
1339
|
+
"list_protected_branches",
|
|
1340
|
+
"get_protected_branch",
|
|
1341
|
+
"protect_branch",
|
|
1342
|
+
"unprotect_branch",
|
|
1343
|
+
"update_default_branch",
|
|
1309
1344
|
"list_commits",
|
|
1310
1345
|
"get_commit",
|
|
1311
1346
|
"get_commit_diff",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.19",
|
|
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": [
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"changelog": "auto-changelog -p",
|
|
52
52
|
"test": "npm run test:all",
|
|
53
53
|
"test:all": "npm run build && npm run test:mock && npm run test:live",
|
|
54
|
-
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
|
|
54
|
+
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
|
|
55
55
|
"test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
|
|
56
56
|
"test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
|
|
57
57
|
"test:live": "node test/validate-api.js",
|