@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 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
- * but the transport session remains active
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.
@@ -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
- constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes, callbackProxyEnabled = false, callbackUrl = "", stateless = null) {
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
- return entry.tokens;
382
+ tokens = entry.tokens;
377
383
  }
378
- // --- Passthrough mode (original behavior) ---
379
- const params = new URLSearchParams({
380
- grant_type: "authorization_code",
381
- client_id: this._gitlabAppId,
382
- code: authorizationCode,
383
- });
384
- if (codeVerifier)
385
- params.append("code_verifier", codeVerifier);
386
- if (redirectUri)
387
- params.append("redirect_uri", redirectUri);
388
- if (resource)
389
- params.append("resource", resource.href);
390
- const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
391
- method: "POST",
392
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
393
- body: params.toString(),
394
- });
395
- if (!response.ok) {
396
- const body = await response.text();
397
- logger.error(`Token exchange failed (${response.status}): ${body}`);
398
- throw new ServerError(`Token exchange failed: ${response.status}`);
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
- const data = await response.json();
401
- return OAuthTokensSchema.parse(data);
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((req, res) => {
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
+ });
@@ -19,7 +19,7 @@ const TOOLSET_TOOL_COUNTS = {
19
19
  merge_requests: 41,
20
20
  issues: 24,
21
21
  repositories: 7,
22
- branches: 10,
22
+ branches: 15,
23
23
  projects: 9,
24
24
  labels: 5,
25
25
  ci: 2,
@@ -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.18",
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",