@zereight/mcp-gitlab 2.1.18 → 2.1.20

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
  /**
@@ -128,18 +128,18 @@ import { createGitLabOAuthProvider } from "./oauth-proxy.js";
128
128
  import { mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js";
129
129
  import { normalizeGitLabApiUrl } from "./utils/url.js";
130
130
  import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents } from "./utils/helpers.js";
131
- import { sanitizeToolArguments } from "./utils/tool-args.js";
131
+ import { cleanMutuallyExclusiveIdUsernameOptions, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS, sanitizeToolArguments, } from "./utils/tool-args.js";
132
132
  import { parseSearchReplaceBlocks, applySearchReplace, applyUnifiedDiff, } from "./utils/patch-helper.js";
133
133
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
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";
@@ -7074,7 +7074,8 @@ async function handleToolCall(params) {
7074
7074
  case "list_issues": {
7075
7075
  const args = ListIssuesSchema.parse(params.arguments);
7076
7076
  const { project_id, ...options } = args;
7077
- const issues = await listIssues(project_id, options);
7077
+ const cleanedOptions = cleanMutuallyExclusiveIdUsernameOptions(options);
7078
+ const issues = await listIssues(project_id, cleanedOptions);
7078
7079
  return {
7079
7080
  content: [{ type: "text", text: JSON.stringify(issues, null, 2) }],
7080
7081
  };
@@ -7743,18 +7744,7 @@ async function handleToolCall(params) {
7743
7744
  }
7744
7745
  case "list_merge_requests": {
7745
7746
  const { project_id, ...options } = ListMergeRequestsSchema.parse(params.arguments);
7746
- // GitLab API treats _id and _username as mutually exclusive for these fields.
7747
- // When both are provided, prefer _username and remove _id to avoid 400 errors.
7748
- const cleanedOptions = { ...options };
7749
- if (cleanedOptions.author_id && cleanedOptions.author_username) {
7750
- delete cleanedOptions.author_id;
7751
- }
7752
- if (cleanedOptions.assignee_id && cleanedOptions.assignee_username) {
7753
- delete cleanedOptions.assignee_id;
7754
- }
7755
- if (cleanedOptions.reviewer_id && cleanedOptions.reviewer_username) {
7756
- delete cleanedOptions.reviewer_id;
7757
- }
7747
+ const cleanedOptions = cleanMutuallyExclusiveIdUsernameOptions(options, LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS);
7758
7748
  const mergeRequests = await listMergeRequests(project_id, cleanedOptions);
7759
7749
  return {
7760
7750
  content: [{ type: "text", text: JSON.stringify(mergeRequests, null, 2) }],
@@ -8315,6 +8305,97 @@ async function handleToolCall(params) {
8315
8305
  content: [{ type: "text", text: JSON.stringify({ status: "deleted", branch: args.branch_name }, null, 2) }],
8316
8306
  };
8317
8307
  }
8308
+ case "list_protected_branches": {
8309
+ const args = ListProtectedBranchesSchema.parse(params.arguments);
8310
+ const projectId = decodeURIComponent(args.project_id);
8311
+ const effectiveProjectId = getEffectiveProjectId(projectId);
8312
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/protected_branches`);
8313
+ if (args.search)
8314
+ url.searchParams.append("search", args.search);
8315
+ if (args.page)
8316
+ url.searchParams.append("page", String(args.page));
8317
+ if (args.per_page)
8318
+ url.searchParams.append("per_page", String(args.per_page));
8319
+ const response = await fetch(url.toString(), {
8320
+ ...getFetchConfig(),
8321
+ });
8322
+ await handleGitLabError(response);
8323
+ const data = z.array(GitLabProtectedBranchSchema).parse(await response.json());
8324
+ return {
8325
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
8326
+ };
8327
+ }
8328
+ case "get_protected_branch": {
8329
+ const args = GetProtectedBranchSchema.parse(params.arguments);
8330
+ const projectId = decodeURIComponent(args.project_id);
8331
+ const effectiveProjectId = getEffectiveProjectId(projectId);
8332
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/protected_branches/${encodeURIComponent(args.branch_name)}`);
8333
+ const response = await fetch(url.toString(), {
8334
+ ...getFetchConfig(),
8335
+ });
8336
+ await handleGitLabError(response);
8337
+ const data = GitLabProtectedBranchSchema.parse(await response.json());
8338
+ return {
8339
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
8340
+ };
8341
+ }
8342
+ case "protect_branch": {
8343
+ const args = ProtectBranchSchema.parse(params.arguments);
8344
+ const projectId = decodeURIComponent(args.project_id);
8345
+ const effectiveProjectId = getEffectiveProjectId(projectId);
8346
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/protected_branches`);
8347
+ const body = { name: args.branch_name };
8348
+ if (args.push_access_level !== undefined)
8349
+ body.push_access_level = args.push_access_level;
8350
+ if (args.merge_access_level !== undefined)
8351
+ body.merge_access_level = args.merge_access_level;
8352
+ if (args.unprotect_access_level !== undefined)
8353
+ body.unprotect_access_level = args.unprotect_access_level;
8354
+ if (args.allow_force_push !== undefined)
8355
+ body.allow_force_push = args.allow_force_push;
8356
+ if (args.code_owner_approval_required !== undefined)
8357
+ body.code_owner_approval_required = args.code_owner_approval_required;
8358
+ const response = await fetch(url.toString(), {
8359
+ ...getFetchConfig(),
8360
+ method: "POST",
8361
+ body: JSON.stringify(body),
8362
+ });
8363
+ await handleGitLabError(response);
8364
+ const data = GitLabProtectedBranchSchema.parse(await response.json());
8365
+ return {
8366
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
8367
+ };
8368
+ }
8369
+ case "unprotect_branch": {
8370
+ const args = UnprotectBranchSchema.parse(params.arguments);
8371
+ const projectId = decodeURIComponent(args.project_id);
8372
+ const effectiveProjectId = getEffectiveProjectId(projectId);
8373
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/protected_branches/${encodeURIComponent(args.branch_name)}`);
8374
+ const response = await fetch(url.toString(), {
8375
+ ...getFetchConfig(),
8376
+ method: "DELETE",
8377
+ });
8378
+ await handleGitLabError(response);
8379
+ return {
8380
+ content: [{ type: "text", text: JSON.stringify({ status: "unprotected", branch: args.branch_name }, null, 2) }],
8381
+ };
8382
+ }
8383
+ case "update_default_branch": {
8384
+ const args = UpdateDefaultBranchSchema.parse(params.arguments);
8385
+ const projectId = decodeURIComponent(args.project_id);
8386
+ const effectiveProjectId = getEffectiveProjectId(projectId);
8387
+ const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}`);
8388
+ const response = await fetch(url.toString(), {
8389
+ ...getFetchConfig(),
8390
+ method: "PUT",
8391
+ body: JSON.stringify({ default_branch: args.default_branch }),
8392
+ });
8393
+ await handleGitLabError(response);
8394
+ const data = await response.json();
8395
+ return {
8396
+ content: [{ type: "text", text: JSON.stringify({ status: "updated", default_branch: args.default_branch, project: data }, null, 2) }],
8397
+ };
8398
+ }
8318
8399
  default:
8319
8400
  throw new Error(`Unknown tool: ${params.name}`);
8320
8401
  }
@@ -8719,9 +8800,9 @@ async function startStreamableHTTPServer() {
8719
8800
  return null;
8720
8801
  };
8721
8802
  /**
8722
- * Set or reset timeout for session auth
8803
+ * Set or reset timeout for session auth.
8723
8804
  * After SESSION_TIMEOUT_SECONDS of inactivity, the auth token is removed
8724
- * but the transport session remains active
8805
+ * and the Streamable HTTP transport is closed so the session slot is released.
8725
8806
  */
8726
8807
  const setAuthTimeout = (sessionId) => {
8727
8808
  // Clear existing timeout if any
@@ -8733,6 +8814,13 @@ async function startStreamableHTTPServer() {
8733
8814
  delete authBySession[sessionId];
8734
8815
  delete authTimeouts[sessionId];
8735
8816
  metrics.expiredSessions++;
8817
+ // Close the transport to free the slot; without this, stale sessions accumulate and exhaust MAX_SESSIONS.
8818
+ const transport = streamableTransports[sessionId];
8819
+ if (transport) {
8820
+ transport.close().catch(err => {
8821
+ logger.error(`Error closing transport for expired session ${sessionId}:`, err);
8822
+ });
8823
+ }
8736
8824
  }
8737
8825
  }, SESSION_TIMEOUT_SECONDS * 1000);
8738
8826
  };
@@ -8957,7 +9045,7 @@ async function startStreamableHTTPServer() {
8957
9045
  storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
8958
9046
  }
8959
9047
  : 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);
9048
+ 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
9049
  const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
8962
9050
  // When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
8963
9051
  // 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({
@@ -1758,13 +1831,19 @@ export const ListIssuesSchema = z
1758
1831
  assignee_id: z.coerce
1759
1832
  .string()
1760
1833
  .optional()
1761
- .describe("Return issues assigned to the given user ID. user id or none or any"),
1834
+ .describe("Return issues assigned to the given user ID (user id, none, or any). Mutually exclusive with assignee_username."),
1762
1835
  assignee_username: z
1763
1836
  .array(z.string())
1764
1837
  .optional()
1765
- .describe("Return issues assigned to the given username"),
1766
- author_id: z.coerce.string().optional().describe("Return issues created by the given user ID"),
1767
- author_username: z.string().optional().describe("Return issues created by the given username"),
1838
+ .describe("Return issues assigned to the given username. Mutually exclusive with assignee_id."),
1839
+ author_id: z.coerce
1840
+ .string()
1841
+ .optional()
1842
+ .describe("Return issues created by the given user ID. Mutually exclusive with author_username."),
1843
+ author_username: z
1844
+ .string()
1845
+ .optional()
1846
+ .describe("Return issues created by the given username. Mutually exclusive with author_id."),
1768
1847
  confidential: z.coerce.boolean().optional().describe("Filter confidential or public issues"),
1769
1848
  created_after: z.string().optional().describe("Return issues created after the given time"),
1770
1849
  created_before: z.string().optional().describe("Return issues created before the given time"),