@zereight/mcp-gitlab 2.1.45 → 2.1.47
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 +4 -1
- package/README.md +135 -129
- package/README.zh-CN.md +4 -1
- package/build/config.js +1 -1
- package/build/index.js +87 -122
- package/build/oauth-proxy.js +151 -87
- package/build/schemas.js +20 -2
- package/build/server/request-helpers.js +63 -0
- package/build/server/version.js +28 -0
- package/build/stateless/consumed-proxy-code-cache.js +82 -0
- package/build/stateless/index.js +1 -0
- package/build/stateless/stored-tokens.js +4 -2
- package/build/test/mcp-oauth-tests.js +96 -0
- package/build/test/nullable-gitlab-response-fields.test.js +16 -1
- package/build/test/server/request-helpers.test.js +33 -0
- package/build/test/server/version.test.js +13 -0
- package/build/test/stateless/callback-proxy.test.js +109 -0
- package/build/test/stateless/consumed-proxy-code-cache.test.js +150 -0
- package/build/test/test-download-attachment.js +27 -0
- package/build/test/test-get-file-contents.js +128 -0
- package/build/test/test-job-artifacts.js +27 -2
- package/build/test/test-list-group-members.js +150 -0
- package/build/test/test-merge-request-approval-state-tools.js +42 -0
- package/build/test/test-toolset-filtering.js +1 -1
- package/build/test/test-upload-markdown.js +38 -7
- package/build/test/utils/helpers-path.test.js +104 -0
- package/build/tools/registry.js +8 -1
- package/build/utils/helpers.js +205 -0
- package/build/utils/logger.js +4 -0
- package/package.json +2 -2
package/build/index.js
CHANGED
|
@@ -70,9 +70,9 @@ import fs from "node:fs";
|
|
|
70
70
|
import { pipeline as streamPipeline } from "node:stream/promises";
|
|
71
71
|
import os from "node:os";
|
|
72
72
|
import nodeFetch from "node-fetch";
|
|
73
|
-
import path
|
|
73
|
+
import path from "node:path";
|
|
74
74
|
import { CookieJar, parse as parseCookie } from "tough-cookie";
|
|
75
|
-
import {
|
|
75
|
+
import { URL } from "node:url";
|
|
76
76
|
import { z } from "zod";
|
|
77
77
|
import { initializeOAuthClient } from "./oauth.js";
|
|
78
78
|
import { createGitLabOAuthProvider } from "./oauth-proxy.js";
|
|
@@ -84,8 +84,11 @@ import { registerDownloadProxy } from "./downloads/proxy.js";
|
|
|
84
84
|
import { createDownloadToken } from "./utils/download-token.js";
|
|
85
85
|
import { determineTransportMode, TransportMode } from "./server/transport-mode.js";
|
|
86
86
|
import { formatPrometheusMetrics } from "./server/metrics.js";
|
|
87
|
+
import { SERVER_VERSION } from "./server/version.js";
|
|
88
|
+
import { isInitializationRequestBody, isUnauthenticatedDiscoveryRequestBody, readMcpSessionIdHeader, redactSessionIdForLog, } from "./server/request-helpers.js";
|
|
89
|
+
export { readMcpSessionIdHeader } from "./server/request-helpers.js";
|
|
87
90
|
import { normalizeGitLabApiUrl } from "./utils/url.js";
|
|
88
|
-
import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents, } from "./utils/helpers.js";
|
|
91
|
+
import { estimateMergeCommitCount, filterDiffsByPatterns, openSafeOutputWriteStream, readSafeExistingFile, summarizeWebhookEvents, } from "./utils/helpers.js";
|
|
89
92
|
import { graphqlQueryContainsWriteOperation, graphqlQueryContainsDeleteOperation, } from "./utils/graphql-query.js";
|
|
90
93
|
import { resolveNestedWikiUpdateTitle } from "./utils/wiki-title.js";
|
|
91
94
|
import { redactSensitiveGitLabFields } from "./utils/redact-sensitive.js";
|
|
@@ -105,26 +108,10 @@ GitLabDiscussionNoteSchema, // Added
|
|
|
105
108
|
GitLabDiscussionSchema,
|
|
106
109
|
// Draft Notes Schemas
|
|
107
110
|
GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, 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, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
|
|
108
|
-
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, 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, UpdateProjectSchema, 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";
|
|
111
|
+
GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, 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, UpdateProjectSchema, 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";
|
|
109
112
|
import { randomUUID, timingSafeEqual, } from "node:crypto";
|
|
110
113
|
import { createLogger } from "./utils/logger.js";
|
|
111
114
|
const logger = createLogger();
|
|
112
|
-
/**
|
|
113
|
-
* Read version from package.json
|
|
114
|
-
*/
|
|
115
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
116
|
-
const __dirname = dirname(__filename);
|
|
117
|
-
const packageJsonPath = path.resolve(__dirname, "../package.json");
|
|
118
|
-
let SERVER_VERSION = "unknown";
|
|
119
|
-
try {
|
|
120
|
-
if (fs.existsSync(packageJsonPath)) {
|
|
121
|
-
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
122
|
-
SERVER_VERSION = packageJson.version || SERVER_VERSION;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
// Intentionally ignored: version read failure is non-critical
|
|
127
|
-
}
|
|
128
115
|
const SERVER_NAME = process.env.MCP_SERVER_NAME?.trim() || "zereight-gitlab-mcp-server";
|
|
129
116
|
/**
|
|
130
117
|
* Create a new MCP Server instance with request handlers registered.
|
|
@@ -650,71 +637,6 @@ function validateConfiguration() {
|
|
|
650
637
|
}
|
|
651
638
|
let OAUTH_ACCESS_TOKEN = null;
|
|
652
639
|
let oauthClient = null;
|
|
653
|
-
/**
|
|
654
|
-
* Produce a safe short form of a session id for logs. Legacy UUIDs pass
|
|
655
|
-
* through; stateless sealed sids are truncated to the "v1.sid." prefix
|
|
656
|
-
* plus a handful of bytes of the ciphertext so operators can correlate
|
|
657
|
-
* flows without the log line carrying the sealed bearer token.
|
|
658
|
-
*/
|
|
659
|
-
function redactSessionIdForLog(sid) {
|
|
660
|
-
if (!sid)
|
|
661
|
-
return "<none>";
|
|
662
|
-
if (sid.startsWith("v1.sid."))
|
|
663
|
-
return "v1.sid.<redacted>";
|
|
664
|
-
// UUIDs / other formats are low-sensitivity; show first 8 chars.
|
|
665
|
-
return sid.length > 8 ? `${sid.slice(0, 8)}…` : sid;
|
|
666
|
-
}
|
|
667
|
-
/**
|
|
668
|
-
* Detect whether an MCP JSON-RPC request body represents an "initialize"
|
|
669
|
-
* request. Accepts both single-message and batch forms. Returns false on any
|
|
670
|
-
* unexpected shape — callers treat an ambiguous body as non-init, which is
|
|
671
|
-
* the safer default (it means the SDK will fail loudly rather than silently
|
|
672
|
-
* spawning a new session).
|
|
673
|
-
*/
|
|
674
|
-
function isInitializationRequestBody(body) {
|
|
675
|
-
if (!body)
|
|
676
|
-
return false;
|
|
677
|
-
const isInitObj = (m) => typeof m === "object" && m !== null && m.method === "initialize";
|
|
678
|
-
if (Array.isArray(body))
|
|
679
|
-
return body.some(isInitObj);
|
|
680
|
-
return isInitObj(body);
|
|
681
|
-
}
|
|
682
|
-
function isUnauthenticatedDiscoveryRequestBody(body) {
|
|
683
|
-
if (!body)
|
|
684
|
-
return false;
|
|
685
|
-
const isDiscoveryMethod = (m) => {
|
|
686
|
-
if (typeof m !== "object" || m === null)
|
|
687
|
-
return false;
|
|
688
|
-
const method = m.method;
|
|
689
|
-
return (method === "initialize" || method === "notifications/initialized" || method === "tools/list");
|
|
690
|
-
};
|
|
691
|
-
if (Array.isArray(body))
|
|
692
|
-
return body.every(isDiscoveryMethod);
|
|
693
|
-
return isDiscoveryMethod(body);
|
|
694
|
-
}
|
|
695
|
-
/**
|
|
696
|
-
* Normalize an `Mcp-Session-Id` header value.
|
|
697
|
-
*
|
|
698
|
-
* Node's HTTP types allow any request header to surface as `string[]` when
|
|
699
|
-
* the client sends it more than once. Casting to `string` and calling
|
|
700
|
-
* `.startsWith()` on an array throws `TypeError: startsWith is not a
|
|
701
|
-
* function`, which Express converts to a 500 — silently turning malformed
|
|
702
|
-
* requests into server errors and breaking the 401/404 semantics we
|
|
703
|
-
* carefully distinguish in stateless mode. A duplicated `Mcp-Session-Id` is
|
|
704
|
-
* also ill-formed at the protocol level: there is no well-defined way to
|
|
705
|
-
* pick between two values, so we reject arrays rather than guess.
|
|
706
|
-
*
|
|
707
|
-
* Empty-string is normalized to `undefined` so call sites can use truthy
|
|
708
|
-
* checks and `?? undefined`-style fallbacks uniformly.
|
|
709
|
-
*
|
|
710
|
-
* Exported for unit tests; otherwise used only by the /mcp handlers below.
|
|
711
|
-
*/
|
|
712
|
-
export function readMcpSessionIdHeader(req) {
|
|
713
|
-
const raw = req.headers["mcp-session-id"];
|
|
714
|
-
if (typeof raw !== "string")
|
|
715
|
-
return undefined;
|
|
716
|
-
return raw.length > 0 ? raw : undefined;
|
|
717
|
-
}
|
|
718
640
|
/**
|
|
719
641
|
* Loaded once at startup. Null when OAUTH_STATELESS_MODE is disabled.
|
|
720
642
|
* When set, the OAuth provider and (later phases) the Mcp-Session-Id path
|
|
@@ -998,7 +920,7 @@ const BASE_HEADERS = {
|
|
|
998
920
|
function buildAuthHeaders() {
|
|
999
921
|
if (REMOTE_AUTHORIZATION || GITLAB_MCP_OAUTH) {
|
|
1000
922
|
const ctx = sessionAuthStore.getStore();
|
|
1001
|
-
logger.debug({
|
|
923
|
+
logger.debug({ sessionId: ctx?.sessionId, header: ctx?.header, apiUrl: ctx?.apiUrl }, "buildAuthHeaders: session context");
|
|
1002
924
|
if (ctx?.token) {
|
|
1003
925
|
return {
|
|
1004
926
|
[ctx.header]: ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token,
|
|
@@ -1043,7 +965,7 @@ function getEffectiveApiUrl() {
|
|
|
1043
965
|
if (ctx?.apiUrl) {
|
|
1044
966
|
return ctx.apiUrl;
|
|
1045
967
|
}
|
|
1046
|
-
logger.warn({ ctx }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
|
|
968
|
+
logger.warn({ sessionId: ctx?.sessionId, header: ctx?.header, apiUrl: ctx?.apiUrl }, "getEffectiveApiUrl: No context or apiUrl found, falling back to default");
|
|
1047
969
|
}
|
|
1048
970
|
return GITLAB_API_URL;
|
|
1049
971
|
}
|
|
@@ -1344,10 +1266,14 @@ async function getFileContents(projectId, filePath, ref) {
|
|
|
1344
1266
|
await handleGitLabError(response);
|
|
1345
1267
|
const data = await response.json();
|
|
1346
1268
|
const parsedData = GitLabContentSchema.parse(data);
|
|
1347
|
-
// Decode Base64-encoded
|
|
1348
|
-
if (!Array.isArray(parsedData) && parsedData.content) {
|
|
1349
|
-
|
|
1350
|
-
|
|
1269
|
+
// Decode Base64-encoded text files while preserving binary content losslessly.
|
|
1270
|
+
if (!Array.isArray(parsedData) && typeof parsedData.content === "string") {
|
|
1271
|
+
const decodedContent = Buffer.from(parsedData.content, "base64");
|
|
1272
|
+
const utf8Content = decodedContent.toString("utf8");
|
|
1273
|
+
if (Buffer.from(utf8Content, "utf8").equals(decodedContent)) {
|
|
1274
|
+
parsedData.content = utf8Content;
|
|
1275
|
+
parsedData.encoding = "utf8";
|
|
1276
|
+
}
|
|
1351
1277
|
}
|
|
1352
1278
|
return parsedData;
|
|
1353
1279
|
}
|
|
@@ -4123,13 +4049,33 @@ async function getMergeRequestApprovalState(projectId, mergeRequestIid) {
|
|
|
4123
4049
|
...getFetchConfig(),
|
|
4124
4050
|
method: "GET",
|
|
4125
4051
|
});
|
|
4126
|
-
|
|
4052
|
+
// 404 when the endpoint is unavailable; 402/403 when the instance or namespace is
|
|
4053
|
+
// not licensed for approval rules, which are a paid-tier feature.
|
|
4054
|
+
if (approvalStateResponse.status === 404 ||
|
|
4055
|
+
approvalStateResponse.status === 402 ||
|
|
4056
|
+
approvalStateResponse.status === 403) {
|
|
4127
4057
|
return getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
|
|
4128
4058
|
}
|
|
4129
4059
|
await handleGitLabError(approvalStateResponse);
|
|
4130
4060
|
const parsedApprovalState = GitLabMergeRequestApprovalStateSchema.parse(await approvalStateResponse.json());
|
|
4131
4061
|
const approvedByUsers = getUniqueApprovalUsers((parsedApprovalState.rules || []).flatMap(rule => rule.approved_by || []));
|
|
4132
4062
|
const approvedByUsernames = approvedByUsers.map(user => user.username);
|
|
4063
|
+
// approval_state only reports approvals attributed to rules, so a project with no
|
|
4064
|
+
// approval rules answers 200 with `rules: []` and yields no approvers even when
|
|
4065
|
+
// someone has approved. Read /approvals in that case. A non-empty rules array means
|
|
4066
|
+
// rules are in use and approval_state is authoritative, so no extra request is made
|
|
4067
|
+
// there - including while an MR is still waiting for its first approval.
|
|
4068
|
+
if ((parsedApprovalState.rules || []).length === 0) {
|
|
4069
|
+
try {
|
|
4070
|
+
const viaApprovals = await getMergeRequestApprovalsFallback(projectId, mergeRequestIid);
|
|
4071
|
+
if ((viaApprovals.approved_by ?? []).length > 0) {
|
|
4072
|
+
return viaApprovals;
|
|
4073
|
+
}
|
|
4074
|
+
}
|
|
4075
|
+
catch {
|
|
4076
|
+
// /approvals unavailable as well - keep the approval_state answer below.
|
|
4077
|
+
}
|
|
4078
|
+
}
|
|
4133
4079
|
return {
|
|
4134
4080
|
...parsedApprovalState,
|
|
4135
4081
|
approved_by: approvedByUsers,
|
|
@@ -5334,12 +5280,11 @@ async function downloadJobArtifacts(projectId, jobId, localPath) {
|
|
|
5334
5280
|
}
|
|
5335
5281
|
await handleGitLabError(response);
|
|
5336
5282
|
const filename = `artifacts_job_${encodeGitLabPathSegment(jobId)}.zip`;
|
|
5337
|
-
const savePath = localPath ? path.join(localPath, filename) : filename;
|
|
5338
|
-
fs.mkdirSync(path.dirname(savePath), { recursive: true });
|
|
5339
5283
|
if (!response.body) {
|
|
5340
5284
|
throw new Error("No response body from GitLab");
|
|
5341
5285
|
}
|
|
5342
|
-
|
|
5286
|
+
const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(filename, localPath, "local_path");
|
|
5287
|
+
await streamPipeline(response.body, saveStream);
|
|
5343
5288
|
return savePath;
|
|
5344
5289
|
}
|
|
5345
5290
|
/**
|
|
@@ -6168,6 +6113,35 @@ async function listProjectMembers(projectId, options = {}) {
|
|
|
6168
6113
|
const data = await response.json();
|
|
6169
6114
|
return z.array(GitLabProjectMemberSchema).parse(data);
|
|
6170
6115
|
}
|
|
6116
|
+
/**
|
|
6117
|
+
* List members of a GitLab group
|
|
6118
|
+
* GitLab 그룹 멤버 목록 조회
|
|
6119
|
+
*
|
|
6120
|
+
* @param {string} groupId - Group ID or URL-encoded path
|
|
6121
|
+
* @param {Omit<ListGroupMembersOptions, "group_id">} options - Options for filtering members
|
|
6122
|
+
* @returns {Promise<GitLabProjectMember[]>} List of group members
|
|
6123
|
+
*/
|
|
6124
|
+
async function listGroupMembers(groupId, options = {}) {
|
|
6125
|
+
groupId = decodeURIComponent(groupId);
|
|
6126
|
+
const membersPath = options.include_inheritance ? "members/all" : "members";
|
|
6127
|
+
const url = new URL(`${getEffectiveApiUrl()}/groups/${encodeURIComponent(groupId)}/${membersPath}`);
|
|
6128
|
+
if (options.query)
|
|
6129
|
+
url.searchParams.append("query", options.query);
|
|
6130
|
+
if (options.user_ids) {
|
|
6131
|
+
options.user_ids.forEach(id => url.searchParams.append("user_ids[]", id.toString()));
|
|
6132
|
+
}
|
|
6133
|
+
if (options.skip_users) {
|
|
6134
|
+
options.skip_users.forEach(id => url.searchParams.append("skip_users[]", id.toString()));
|
|
6135
|
+
}
|
|
6136
|
+
if (options.per_page)
|
|
6137
|
+
url.searchParams.append("per_page", options.per_page.toString());
|
|
6138
|
+
if (options.page)
|
|
6139
|
+
url.searchParams.append("page", options.page.toString());
|
|
6140
|
+
const response = await fetch(url.toString(), getFetchConfig());
|
|
6141
|
+
await handleGitLabError(response);
|
|
6142
|
+
const data = await response.json();
|
|
6143
|
+
return z.array(GitLabProjectMemberSchema).parse(data);
|
|
6144
|
+
}
|
|
6171
6145
|
/**
|
|
6172
6146
|
* list group iterations
|
|
6173
6147
|
*
|
|
@@ -6708,12 +6682,10 @@ async function markdownUpload(projectId, filePath, content, filename) {
|
|
|
6708
6682
|
fileName = filename || "upload";
|
|
6709
6683
|
}
|
|
6710
6684
|
else if (filePath) {
|
|
6711
|
-
// Local file mode
|
|
6712
|
-
|
|
6713
|
-
|
|
6714
|
-
|
|
6715
|
-
fileBuffer = fs.readFileSync(filePath);
|
|
6716
|
-
fileName = path.basename(filePath);
|
|
6685
|
+
// Local file mode — reject absolute/traversal/symlink escapes before reading
|
|
6686
|
+
const { buffer, basename: safeBasename } = readSafeExistingFile(filePath, "file_path");
|
|
6687
|
+
fileBuffer = buffer;
|
|
6688
|
+
fileName = safeBasename;
|
|
6717
6689
|
}
|
|
6718
6690
|
else {
|
|
6719
6691
|
throw new Error("Either file_path or content must be provided");
|
|
@@ -6778,29 +6750,12 @@ async function downloadAttachment(projectId, secret, filename, localPath) {
|
|
|
6778
6750
|
// For non-image files, always save to disk.
|
|
6779
6751
|
// For image files, only save to disk if local_path is explicitly provided.
|
|
6780
6752
|
if (!mimeType || localPath) {
|
|
6781
|
-
let savePath;
|
|
6782
|
-
if (localPath) {
|
|
6783
|
-
const normalizedLocalPath = path.normalize(localPath);
|
|
6784
|
-
if (path.isAbsolute(normalizedLocalPath) ||
|
|
6785
|
-
normalizedLocalPath === ".." ||
|
|
6786
|
-
normalizedLocalPath.startsWith(".." + path.sep) ||
|
|
6787
|
-
normalizedLocalPath.includes(path.sep + ".." + path.sep)) {
|
|
6788
|
-
throw new Error("Invalid local_path: directory traversal is not allowed.");
|
|
6789
|
-
}
|
|
6790
|
-
savePath = path.join(normalizedLocalPath, safeFilename);
|
|
6791
|
-
}
|
|
6792
|
-
else {
|
|
6793
|
-
savePath = safeFilename;
|
|
6794
|
-
}
|
|
6795
|
-
const dir = path.dirname(savePath);
|
|
6796
|
-
if (!fs.existsSync(dir)) {
|
|
6797
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
6798
|
-
}
|
|
6799
|
-
// Stream directly to disk instead of buffering in memory
|
|
6800
6753
|
if (!response.body) {
|
|
6801
6754
|
throw new Error("No response body from GitLab");
|
|
6802
6755
|
}
|
|
6803
|
-
|
|
6756
|
+
const { stream: saveStream, path: savePath } = openSafeOutputWriteStream(safeFilename, localPath, "local_path");
|
|
6757
|
+
// Stream directly to disk instead of buffering in memory
|
|
6758
|
+
await streamPipeline(response.body, saveStream);
|
|
6804
6759
|
return { buffer: Buffer.alloc(0), filename: safeFilename, mimeType, savedPath: savePath };
|
|
6805
6760
|
}
|
|
6806
6761
|
// Images returned inline — buffer into memory for base64 encoding
|
|
@@ -7781,6 +7736,14 @@ async function handleToolCall(params) {
|
|
|
7781
7736
|
content: [{ type: "text", text: JSON.stringify(members) }],
|
|
7782
7737
|
};
|
|
7783
7738
|
}
|
|
7739
|
+
case "list_group_members": {
|
|
7740
|
+
const args = ListGroupMembersSchema.parse(params.arguments);
|
|
7741
|
+
const { group_id, ...options } = args;
|
|
7742
|
+
const members = await listGroupMembers(group_id, options);
|
|
7743
|
+
return {
|
|
7744
|
+
content: [{ type: "text", text: JSON.stringify(members) }],
|
|
7745
|
+
};
|
|
7746
|
+
}
|
|
7784
7747
|
case "get_users": {
|
|
7785
7748
|
const args = GetUsersSchema.parse(params.arguments);
|
|
7786
7749
|
const usersMap = await getUsers(args.usernames);
|
|
@@ -9509,7 +9472,9 @@ async function handleToolCall(params) {
|
|
|
9509
9472
|
}
|
|
9510
9473
|
}
|
|
9511
9474
|
catch (error) {
|
|
9512
|
-
|
|
9475
|
+
// Log tool name only — never dump raw params (may contain approval_password).
|
|
9476
|
+
// Sensitive fields are also covered by REDACT_PATHS if arguments are logged elsewhere.
|
|
9477
|
+
logger.debug({ tool: params.name }, "Tool call failed");
|
|
9513
9478
|
if (error instanceof z.ZodError) {
|
|
9514
9479
|
throw new Error(`Invalid arguments: ${error.errors
|
|
9515
9480
|
.map(e => `${e.path.join(".")}: ${e.message}`)
|
package/build/oauth-proxy.js
CHANGED
|
@@ -49,7 +49,7 @@ import { OAuthTokensSchema } from "@modelcontextprotocol/sdk/shared/auth.js";
|
|
|
49
49
|
import { randomUUID, randomBytes, createHash } from "node:crypto";
|
|
50
50
|
import { looksLikeStatelessClientId, mintClientId, openClientId, } from "./stateless/client-id.js";
|
|
51
51
|
import { looksLikeStatelessState, mintPendingAuthState, openPendingAuthState, } from "./stateless/pending-auth.js";
|
|
52
|
-
import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, } from "./stateless/
|
|
52
|
+
import { looksLikeStatelessStoredTokensCode, mintStoredTokensCode, openStoredTokensCode, ConsumedProxyCodeCache, PROXY_CODE_CACHE_FULL, } from "./stateless/index.js";
|
|
53
53
|
import { createLogger } from "./utils/logger.js";
|
|
54
54
|
const logger = createLogger("gitlab-mcp-oauth-proxy");
|
|
55
55
|
// ---------------------------------------------------------------------------
|
|
@@ -129,6 +129,13 @@ class GitLabOAuthServerProvider {
|
|
|
129
129
|
_callbackUrl;
|
|
130
130
|
_pendingAuth = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
|
|
131
131
|
_storedTokens = new BoundedLRUMap(PENDING_AUTH_MAX_SIZE);
|
|
132
|
+
/**
|
|
133
|
+
* Per-pod replay-prevention cache for sealed (stateless) proxy authorization
|
|
134
|
+
* codes. Keys are SHA-256 hashes of the code; entries are TTL-bound (never
|
|
135
|
+
* LRU-evicted early) and hold pending→consumed state. Cross-pod replay
|
|
136
|
+
* remains mitigated by the short stored-code TTL + PKCE.
|
|
137
|
+
*/
|
|
138
|
+
_usedProxyCodes = new ConsumedProxyCodeCache(PENDING_AUTH_MAX_SIZE);
|
|
132
139
|
// Stateless mode (optional). When set, DCR and callback-proxy state are
|
|
133
140
|
// serialised into opaque OAuth values and the in-memory caches above are
|
|
134
141
|
// bypassed. Enabled independently of callback-proxy mode.
|
|
@@ -325,102 +332,150 @@ class GitLabOAuthServerProvider {
|
|
|
325
332
|
// ---- Token exchange ----------------------------------------------------
|
|
326
333
|
async exchangeAuthorizationCode(client, authorizationCode, codeVerifier, redirectUri, resource) {
|
|
327
334
|
let tokens;
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
335
|
+
/** Reserved sealed-code hash + id; released on failure, committed on success. */
|
|
336
|
+
let reservedProxyCode = null;
|
|
337
|
+
/**
|
|
338
|
+
* Legacy (non-stateless) proxy code taken from `_storedTokens` before
|
|
339
|
+
* binding checks. Restored on binding/PKCE failure so a wrong verifier
|
|
340
|
+
* cannot burn the code for the legitimate client.
|
|
341
|
+
*/
|
|
342
|
+
let legacyRestorable = null;
|
|
343
|
+
try {
|
|
344
|
+
if (this._callbackProxyEnabled) {
|
|
345
|
+
// --- Callback proxy mode ---
|
|
346
|
+
// The authorizationCode is a proxy code we generated in handleCallback().
|
|
347
|
+
// It is either a sealed token (stateless mode) or a random UUID that
|
|
348
|
+
// keys into the _storedTokens LRU (legacy mode).
|
|
349
|
+
const stateless = this._stateless;
|
|
350
|
+
let entry = null;
|
|
351
|
+
if (stateless && looksLikeStatelessStoredTokensCode(authorizationCode)) {
|
|
352
|
+
const codeHash = createHash("sha256").update(authorizationCode).digest("hex");
|
|
353
|
+
let reserved;
|
|
354
|
+
try {
|
|
355
|
+
reserved = this._usedProxyCodes.tryReserve(codeHash, stateless.storedTtlSeconds);
|
|
356
|
+
if (!reserved.ok) {
|
|
357
|
+
if (reserved.reason === "pending") {
|
|
358
|
+
throw new ServerError("Authorization code exchange in progress — please retry");
|
|
359
|
+
}
|
|
360
|
+
throw new ServerError("Authorization code already used");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
if (err instanceof Error && err.message === PROXY_CODE_CACHE_FULL) {
|
|
365
|
+
logger.warn({
|
|
366
|
+
cacheSize: this._usedProxyCodes.size,
|
|
367
|
+
cacheMaxSize: this._usedProxyCodes.maxSize,
|
|
368
|
+
}, "Proxy code replay cache at capacity");
|
|
369
|
+
throw new ServerError("Authorization server busy — please retry the OAuth flow");
|
|
370
|
+
}
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
reservedProxyCode = { hash: codeHash, id: reserved.reservationId };
|
|
374
|
+
const payload = openStoredTokensCode(stateless.material, authorizationCode, stateless.storedTtlSeconds);
|
|
375
|
+
if (!payload) {
|
|
376
|
+
throw new ServerError("Invalid or expired authorization code");
|
|
377
|
+
}
|
|
378
|
+
entry = {
|
|
379
|
+
tokens: payload.t,
|
|
380
|
+
clientId: payload.cid,
|
|
381
|
+
clientCodeChallenge: payload.ccc,
|
|
382
|
+
clientRedirectUri: payload.cru,
|
|
383
|
+
};
|
|
384
|
+
// NOTE: Cross-pod one-time use still requires a shared store. Replay
|
|
385
|
+
// across pods is mitigated by short TTL (default 120s) + client PKCE.
|
|
386
|
+
// Documented in stateless/stored-tokens.ts.
|
|
339
387
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
388
|
+
else {
|
|
389
|
+
// Atomic take: serialize concurrent exchanges. Restored below if
|
|
390
|
+
// client/redirect/PKCE binding checks fail (DoS prevention).
|
|
391
|
+
const lru = this._storedTokens.getAndDelete(authorizationCode);
|
|
392
|
+
if (!lru) {
|
|
393
|
+
throw new ServerError("Invalid or expired authorization code");
|
|
394
|
+
}
|
|
395
|
+
if (Date.now() - lru.createdAt > PENDING_AUTH_TTL_MS) {
|
|
396
|
+
throw new ServerError("Authorization code expired — please restart the OAuth flow");
|
|
397
|
+
}
|
|
398
|
+
legacyRestorable = { code: authorizationCode, entry: lru };
|
|
399
|
+
entry = {
|
|
400
|
+
tokens: lru.tokens,
|
|
401
|
+
clientId: lru.clientId,
|
|
402
|
+
clientCodeChallenge: lru.clientCodeChallenge,
|
|
403
|
+
clientRedirectUri: lru.clientRedirectUri,
|
|
404
|
+
};
|
|
355
405
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
406
|
+
// Bind the proxy code to the client and redirect_uri that initiated
|
|
407
|
+
// /authorize, preserving the normal OAuth authorization-code invariant.
|
|
408
|
+
// Binding + PKCE run while consumption is only *reserved* (pending /
|
|
409
|
+
// restorable); failed checks release so the legitimate client can retry.
|
|
410
|
+
if (client.client_id !== entry.clientId) {
|
|
411
|
+
throw new ServerError("Invalid client for authorization code");
|
|
359
412
|
}
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
throw new ServerError("Invalid redirect_uri for authorization code");
|
|
413
|
+
if (redirectUri !== entry.clientRedirectUri) {
|
|
414
|
+
throw new ServerError("Invalid redirect_uri for authorization code");
|
|
415
|
+
}
|
|
416
|
+
// Verify client PKCE: the client's code_verifier must match the
|
|
417
|
+
// code_challenge stored during /authorize.
|
|
418
|
+
if (entry.clientCodeChallenge) {
|
|
419
|
+
if (!codeVerifier) {
|
|
420
|
+
throw new ServerError("PKCE code_verifier is required");
|
|
421
|
+
}
|
|
422
|
+
const computed = createHash("sha256").update(codeVerifier).digest("base64url");
|
|
423
|
+
if (computed !== entry.clientCodeChallenge) {
|
|
424
|
+
throw new ServerError("PKCE verification failed");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
tokens = entry.tokens;
|
|
376
428
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
429
|
+
else {
|
|
430
|
+
// --- Passthrough mode (original behavior) ---
|
|
431
|
+
const params = new URLSearchParams({
|
|
432
|
+
grant_type: "authorization_code",
|
|
433
|
+
client_id: this._gitlabAppId,
|
|
434
|
+
code: authorizationCode,
|
|
435
|
+
});
|
|
436
|
+
if (codeVerifier)
|
|
437
|
+
params.append("code_verifier", codeVerifier);
|
|
438
|
+
if (redirectUri)
|
|
439
|
+
params.append("redirect_uri", redirectUri);
|
|
440
|
+
if (resource)
|
|
441
|
+
params.append("resource", resource.href);
|
|
442
|
+
const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
445
|
+
body: params.toString(),
|
|
446
|
+
});
|
|
447
|
+
if (!response.ok) {
|
|
448
|
+
const body = await response.text();
|
|
449
|
+
logger.error(`Token exchange failed (${response.status}): ${body}`);
|
|
450
|
+
throw new ServerError(`Token exchange failed: ${response.status}`);
|
|
382
451
|
}
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
452
|
+
const data = await response.json();
|
|
453
|
+
tokens = OAuthTokensSchema.parse(data);
|
|
454
|
+
}
|
|
455
|
+
if (this._allowedGroups) {
|
|
456
|
+
const isMember = await this._checkGroupMembership(tokens.access_token);
|
|
457
|
+
if (!isMember) {
|
|
458
|
+
logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
|
|
459
|
+
throw new ServerError("Access denied: user is not a member of an allowed group");
|
|
386
460
|
}
|
|
387
461
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
grant_type: "authorization_code",
|
|
394
|
-
client_id: this._gitlabAppId,
|
|
395
|
-
code: authorizationCode,
|
|
396
|
-
});
|
|
397
|
-
if (codeVerifier)
|
|
398
|
-
params.append("code_verifier", codeVerifier);
|
|
399
|
-
if (redirectUri)
|
|
400
|
-
params.append("redirect_uri", redirectUri);
|
|
401
|
-
if (resource)
|
|
402
|
-
params.append("resource", resource.href);
|
|
403
|
-
const response = await fetch(`${this._gitlabBaseUrl}/oauth/token`, {
|
|
404
|
-
method: "POST",
|
|
405
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
406
|
-
body: params.toString(),
|
|
407
|
-
});
|
|
408
|
-
if (!response.ok) {
|
|
409
|
-
const body = await response.text();
|
|
410
|
-
logger.error(`Token exchange failed (${response.status}): ${body}`);
|
|
411
|
-
throw new ServerError(`Token exchange failed: ${response.status}`);
|
|
462
|
+
// All checks passed — permanently consume the proxy code.
|
|
463
|
+
legacyRestorable = null;
|
|
464
|
+
if (reservedProxyCode) {
|
|
465
|
+
this._usedProxyCodes.commit(reservedProxyCode.hash, reservedProxyCode.id);
|
|
466
|
+
reservedProxyCode = null;
|
|
412
467
|
}
|
|
413
|
-
|
|
414
|
-
tokens = OAuthTokensSchema.parse(data);
|
|
468
|
+
return tokens;
|
|
415
469
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
logger.warn({ allowedGroups: this._allowedGroups }, "Token issuance denied: user is not a member of any allowed group");
|
|
420
|
-
throw new ServerError("Access denied: user is not a member of an allowed group");
|
|
470
|
+
catch (err) {
|
|
471
|
+
if (legacyRestorable) {
|
|
472
|
+
this._storedTokens.set(legacyRestorable.code, legacyRestorable.entry);
|
|
421
473
|
}
|
|
474
|
+
if (reservedProxyCode) {
|
|
475
|
+
this._usedProxyCodes.release(reservedProxyCode.hash, reservedProxyCode.id);
|
|
476
|
+
}
|
|
477
|
+
throw err;
|
|
422
478
|
}
|
|
423
|
-
return tokens;
|
|
424
479
|
}
|
|
425
480
|
/**
|
|
426
481
|
* Returns true if the token owner belongs to at least one group whose
|
|
@@ -480,9 +535,18 @@ class GitLabOAuthServerProvider {
|
|
|
480
535
|
}
|
|
481
536
|
// ---- Verify access token -----------------------------------------------
|
|
482
537
|
async verifyAccessToken(token) {
|
|
483
|
-
|
|
538
|
+
let res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info`, {
|
|
484
539
|
headers: { Authorization: `Bearer ${token}` },
|
|
485
540
|
});
|
|
541
|
+
if (res.status === 401) {
|
|
542
|
+
// Some GitLab instances sit behind an edge cache that strips the
|
|
543
|
+
// Authorization header on /oauth/* paths (observed on
|
|
544
|
+
// git.drupalcode.org, fronted by Varnish), so a valid token 401s
|
|
545
|
+
// here while working fine against /api/v4. Doorkeeper also accepts
|
|
546
|
+
// the RFC 6750 access_token query parameter — retry with that form
|
|
547
|
+
// before rejecting the token.
|
|
548
|
+
res = await fetch(`${this._gitlabBaseUrl}/oauth/token/info?access_token=${encodeURIComponent(token)}`);
|
|
549
|
+
}
|
|
486
550
|
if (!res.ok) {
|
|
487
551
|
throw new InvalidTokenError("Invalid or expired GitLab OAuth token");
|
|
488
552
|
}
|
package/build/schemas.js
CHANGED
|
@@ -309,8 +309,10 @@ export const GitLabCiLintResultSchema = z.object({
|
|
|
309
309
|
valid: z.coerce.boolean(),
|
|
310
310
|
errors: z.array(z.string()),
|
|
311
311
|
warnings: z.array(z.string()).optional(),
|
|
312
|
-
|
|
313
|
-
|
|
312
|
+
// GitLab sends null (not an omitted field) for both when the config is
|
|
313
|
+
// invalid, which is exactly when this tool is called. See #638.
|
|
314
|
+
merged_yaml: z.string().nullish(),
|
|
315
|
+
includes: z.array(z.unknown()).nullish(),
|
|
314
316
|
jobs: z.array(z.unknown()).optional(),
|
|
315
317
|
});
|
|
316
318
|
export const ValidateCiLintSchema = z.object({
|
|
@@ -2941,6 +2943,22 @@ export const ListProjectMembersSchema = z.object({
|
|
|
2941
2943
|
.describe("Number of items per page (default: 20, max: 100)"),
|
|
2942
2944
|
page: z.coerce.number().optional().describe("Page number for pagination (default: 1)"),
|
|
2943
2945
|
});
|
|
2946
|
+
// Schema for listing group members
|
|
2947
|
+
export const ListGroupMembersSchema = z.object({
|
|
2948
|
+
group_id: z.string().describe("Group ID or URL-encoded path"),
|
|
2949
|
+
query: z.string().optional().describe("Search for members by name or username"),
|
|
2950
|
+
user_ids: z.array(z.coerce.number()).optional().describe("Filter by user IDs"),
|
|
2951
|
+
skip_users: z.array(z.coerce.number()).optional().describe("User IDs to exclude"),
|
|
2952
|
+
include_inheritance: z.coerce
|
|
2953
|
+
.boolean()
|
|
2954
|
+
.optional()
|
|
2955
|
+
.describe("Include inherited members. Defaults to false."),
|
|
2956
|
+
per_page: z.coerce
|
|
2957
|
+
.number()
|
|
2958
|
+
.optional()
|
|
2959
|
+
.describe("Number of items per page (default: 20, max: 100)"),
|
|
2960
|
+
page: z.coerce.number().optional().describe("Page number for pagination (default: 1)"),
|
|
2961
|
+
});
|
|
2944
2962
|
// Schema for GitLab project member
|
|
2945
2963
|
export const GitLabProjectMemberSchema = z.object({
|
|
2946
2964
|
id: z.coerce.number(),
|