@zereight/mcp-gitlab 2.1.56 → 2.1.57

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/build/index.js CHANGED
@@ -69,7 +69,7 @@ import fetchCookie from "fetch-cookie";
69
69
  import fs from "node:fs";
70
70
  import { pipeline as streamPipeline } from "node:stream/promises";
71
71
  import os from "node:os";
72
- import nodeFetch from "node-fetch";
72
+ import { fetch as undiciFetch, File, FormData } from "undici";
73
73
  import path from "node:path";
74
74
  import { CookieJar, parse as parseCookie } from "tough-cookie";
75
75
  import { URL } from "node:url";
@@ -111,7 +111,7 @@ GitLabDiscussionNoteSchema, // Added
111
111
  GitLabDiscussionSchema,
112
112
  // Draft Notes Schemas
113
113
  GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineScheduleSchema, GitLabPipelineScheduleVariableSchema, GitLabPipelineSchedulePlayResultSchema, ListPipelineSchedulesSchema, GetPipelineScheduleSchema, ListPipelineSchedulePipelinesSchema, CreatePipelineScheduleSchema, UpdatePipelineScheduleSchema, DeletePipelineScheduleSchema, PlayPipelineScheduleSchema, TakeOwnershipPipelineScheduleSchema, GetPipelineScheduleVariableSchema, CreatePipelineScheduleVariableSchema, UpdatePipelineScheduleVariableSchema, DeletePipelineScheduleVariableSchema, 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
114
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, 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";
114
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, 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, CreateWebhookSchema, UpdateWebhookSchema, DeleteWebhookSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
115
115
  import { randomUUID, timingSafeEqual, } from "node:crypto";
116
116
  import { createLogger } from "./utils/logger.js";
117
117
  const logger = createLogger();
@@ -913,7 +913,7 @@ function defaultAuthRetryConfig() {
913
913
  }
914
914
  // Cookie jar and fetch - reloaded when cookie file changes
915
915
  let cookieJar = null;
916
- let fetch = wrapWithAuthRetry(nodeFetch, defaultAuthRetryConfig());
916
+ let fetch = wrapWithAuthRetry(undiciFetch, defaultAuthRetryConfig());
917
917
  let lastCookieMtime = 0;
918
918
  let cookieReloadLock = null; // Mutex to prevent parallel reloads
919
919
  // Auth proxies may redirect and set cookies on the first request. We make a throwaway
@@ -933,7 +933,7 @@ async function reloadCookiesIfChanged() {
933
933
  lastCookieMtime = mtime;
934
934
  const newJar = await createCookieJar();
935
935
  cookieJar = newJar;
936
- fetch = wrapWithAuthRetry(newJar ? fetchCookie(nodeFetch, newJar) : nodeFetch, defaultAuthRetryConfig());
936
+ fetch = wrapWithAuthRetry(newJar ? fetchCookie(undiciFetch, newJar) : undiciFetch, defaultAuthRetryConfig());
937
937
  initialSessionRequestMade = false;
938
938
  }
939
939
  }
@@ -942,7 +942,7 @@ async function reloadCookiesIfChanged() {
942
942
  if (cookieJar) {
943
943
  logger.info("Cookie file removed, clearing cached cookies");
944
944
  cookieJar = null;
945
- fetch = wrapWithAuthRetry(nodeFetch, defaultAuthRetryConfig());
945
+ fetch = wrapWithAuthRetry(undiciFetch, defaultAuthRetryConfig());
946
946
  lastCookieMtime = 0;
947
947
  initialSessionRequestMade = false;
948
948
  }
@@ -1060,10 +1060,10 @@ function getEffectiveApiUrl() {
1060
1060
  */
1061
1061
  const getFetchConfig = () => {
1062
1062
  const effectiveApiUrl = getEffectiveApiUrl();
1063
- const agent = clientPool.getAgentFunctionForUrl(effectiveApiUrl);
1063
+ const dispatcher = clientPool.getDispatcherForUrl(effectiveApiUrl);
1064
1064
  return {
1065
1065
  headers: { ...BASE_HEADERS, ...buildAuthHeaders() },
1066
- agent: agent,
1066
+ dispatcher,
1067
1067
  };
1068
1068
  };
1069
1069
  // Compute at startup
@@ -1193,7 +1193,7 @@ if (getPositionalCliCommand(process.argv) !== "auth" &&
1193
1193
  * Utility function for handling GitLab API errors
1194
1194
  * API 에러 처리를 위한 유틸리티 함수 (Utility function for handling API errors)
1195
1195
  *
1196
- * @param {import("node-fetch").Response} response - The response from GitLab API
1196
+ * @param {Response} response - The response from GitLab API
1197
1197
  * @throws {Error} Throws an error with response details if the request failed
1198
1198
  */
1199
1199
  async function handleGitLabError(response) {
@@ -4816,6 +4816,54 @@ async function listWebhooks(options) {
4816
4816
  await handleGitLabError(response);
4817
4817
  return (await response.json());
4818
4818
  }
4819
+ function buildWebhookMutationBody(options) {
4820
+ const body = {};
4821
+ for (const [key, value] of Object.entries(options)) {
4822
+ if (key === "project_id" || key === "group_id" || key === "hook_id")
4823
+ continue;
4824
+ if (value !== undefined)
4825
+ body[key] = value;
4826
+ }
4827
+ return body;
4828
+ }
4829
+ /**
4830
+ * Create a webhook on a project or group
4831
+ */
4832
+ async function createWebhook(options) {
4833
+ const url = buildWebhookBaseUrl(options.project_id, options.group_id);
4834
+ const response = await fetch(url, {
4835
+ ...getFetchConfig(),
4836
+ method: "POST",
4837
+ body: JSON.stringify(buildWebhookMutationBody(options)),
4838
+ });
4839
+ await handleGitLabError(response);
4840
+ return await response.json();
4841
+ }
4842
+ /**
4843
+ * Update a webhook on a project or group
4844
+ */
4845
+ async function updateWebhook(options) {
4846
+ const url = `${buildWebhookBaseUrl(options.project_id, options.group_id)}/${options.hook_id}`;
4847
+ const response = await fetch(url, {
4848
+ ...getFetchConfig(),
4849
+ method: "PUT",
4850
+ body: JSON.stringify(buildWebhookMutationBody(options)),
4851
+ });
4852
+ await handleGitLabError(response);
4853
+ return await response.json();
4854
+ }
4855
+ /**
4856
+ * Delete a webhook from a project or group
4857
+ */
4858
+ async function deleteWebhook(options) {
4859
+ const url = `${buildWebhookBaseUrl(options.project_id, options.group_id)}/${options.hook_id}`;
4860
+ const response = await fetch(url, {
4861
+ ...getFetchConfig(),
4862
+ method: "DELETE",
4863
+ });
4864
+ await handleGitLabError(response);
4865
+ return { status: "deleted", hook_id: options.hook_id };
4866
+ }
4819
4867
  /**
4820
4868
  * Fetch a single page of webhook events
4821
4869
  */
@@ -7015,15 +7063,11 @@ async function markdownUpload(projectId, filePath, content, filename) {
7015
7063
  throw new Error("Either file_path or content must be provided");
7016
7064
  }
7017
7065
  // Create form data
7018
- const FormData = (await import("form-data")).default;
7019
7066
  const form = new FormData();
7020
- form.append("file", fileBuffer, {
7021
- filename: fileName,
7022
- contentType: "application/octet-stream",
7023
- });
7067
+ form.append("file", new File([fileBuffer], fileName, { type: "application/octet-stream" }));
7024
7068
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/uploads`);
7025
7069
  const defaultFetchConfig = getFetchConfig();
7026
- delete defaultFetchConfig.headers["Content-Type"]; // Let form-data set the correct Content-Type with boundary
7070
+ delete defaultFetchConfig.headers["Content-Type"];
7027
7071
  const response = await fetch(url.toString(), {
7028
7072
  ...defaultFetchConfig,
7029
7073
  method: "POST",
@@ -9744,6 +9788,27 @@ async function handleToolCall(params) {
9744
9788
  content: [{ type: "text", text: JSON.stringify(webhooks) }],
9745
9789
  };
9746
9790
  }
9791
+ case "create_webhook": {
9792
+ const args = CreateWebhookSchema.parse(params.arguments);
9793
+ const webhook = await createWebhook(args);
9794
+ return {
9795
+ content: [{ type: "text", text: JSON.stringify(webhook) }],
9796
+ };
9797
+ }
9798
+ case "update_webhook": {
9799
+ const args = UpdateWebhookSchema.parse(params.arguments);
9800
+ const webhook = await updateWebhook(args);
9801
+ return {
9802
+ content: [{ type: "text", text: JSON.stringify(webhook) }],
9803
+ };
9804
+ }
9805
+ case "delete_webhook": {
9806
+ const args = DeleteWebhookSchema.parse(params.arguments);
9807
+ const result = await deleteWebhook(args);
9808
+ return {
9809
+ content: [{ type: "text", text: JSON.stringify(result) }],
9810
+ };
9811
+ }
9747
9812
  case "list_webhook_events": {
9748
9813
  const args = ListWebhookEventsSchema.parse(params.arguments);
9749
9814
  const events = await listWebhookEvents(args);
@@ -9981,8 +10046,8 @@ function buildDownloadProxyDeps() {
9981
10046
  encodeGitLabPathSegment,
9982
10047
  encodeGitLabPath,
9983
10048
  getEffectiveProjectId,
9984
- getAgentFunctionForUrl: clientPool.getAgentFunctionForUrl.bind(clientPool),
9985
- fetch: nodeFetch,
10049
+ getDispatcherForUrl: clientPool.getDispatcherForUrl.bind(clientPool),
10050
+ fetch: undiciFetch,
9986
10051
  logger,
9987
10052
  };
9988
10053
  }
package/build/schemas.js CHANGED
@@ -4091,6 +4091,14 @@ export const CreateTimelineEventSchema = z.object({
4091
4091
  .describe("Timeline event tags to attach. Available: 'Start time', 'End time', 'Impact detected', 'Response initiated', 'Impact mitigated', 'Cause identified'."),
4092
4092
  });
4093
4093
  // --- Webhook schemas ---
4094
+ function hasExactlyOneWebhookScope(data) {
4095
+ const hasProject = Boolean(data.project_id);
4096
+ const hasGroup = Boolean(data.group_id);
4097
+ return (hasProject || hasGroup) && !(hasProject && hasGroup);
4098
+ }
4099
+ const EXACTLY_ONE_WEBHOOK_SCOPE = {
4100
+ message: "Provide exactly one of project_id or group_id",
4101
+ };
4094
4102
  export const ListWebhooksSchema = z
4095
4103
  .object({
4096
4104
  project_id: z.coerce
@@ -4103,9 +4111,7 @@ export const ListWebhooksSchema = z
4103
4111
  .describe("Group ID or URL-encoded path. Provide either project_id or group_id, not both."),
4104
4112
  })
4105
4113
  .merge(PaginationOptionsSchema)
4106
- .refine(data => (data.project_id || data.group_id) && !(data.project_id && data.group_id), {
4107
- message: "Provide exactly one of project_id or group_id",
4108
- });
4114
+ .refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
4109
4115
  export const ListWebhookEventsSchema = z
4110
4116
  .object({
4111
4117
  project_id: z.coerce
@@ -4128,9 +4134,7 @@ export const ListWebhookEventsSchema = z
4128
4134
  per_page: z.number().max(20).optional().default(20).describe("Number of events per page"),
4129
4135
  page: z.coerce.number().optional().describe("Page number for pagination"),
4130
4136
  })
4131
- .refine(data => (data.project_id || data.group_id) && !(data.project_id && data.group_id), {
4132
- message: "Provide exactly one of project_id or group_id",
4133
- });
4137
+ .refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
4134
4138
  export const GetWebhookEventSchema = z
4135
4139
  .object({
4136
4140
  project_id: z.coerce
@@ -4148,9 +4152,115 @@ export const GetWebhookEventSchema = z
4148
4152
  .optional()
4149
4153
  .describe("If known, the page where the event is located (from list_webhook_events). Skips auto-pagination and fetches only this page."),
4150
4154
  })
4151
- .refine(data => (data.project_id || data.group_id) && !(data.project_id && data.group_id), {
4152
- message: "Provide exactly one of project_id or group_id",
4155
+ .refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
4156
+ const WebhookCustomHeaderSchema = z.object({
4157
+ key: z.string().min(1).describe("Custom header name"),
4158
+ value: z.string().describe("Custom header value"),
4153
4159
  });
4160
+ const WebhookMutationFields = {
4161
+ url: z.string().url().describe("Webhook destination URL"),
4162
+ name: z.string().optional().describe("Display name for the webhook"),
4163
+ description: z.string().optional().describe("Description of the webhook"),
4164
+ token: z
4165
+ .string()
4166
+ .optional()
4167
+ .describe("Secret token to validate received payloads. Not returned by GitLab in responses."),
4168
+ signing_token: z
4169
+ .string()
4170
+ .optional()
4171
+ .describe("HMAC signing token in whsec_<base64> form, where the Base64 suffix encodes a 32-byte key. Used for the webhook-signature header. Not returned by GitLab in responses."),
4172
+ enable_ssl_verification: z
4173
+ .boolean()
4174
+ .optional()
4175
+ .describe("Verify SSL when delivering the webhook"),
4176
+ push_events: z.boolean().optional().describe("Trigger on push events"),
4177
+ push_events_branch_filter: z
4178
+ .string()
4179
+ .optional()
4180
+ .describe("Only trigger push events for matching branches"),
4181
+ branch_filter_strategy: z
4182
+ .enum(["wildcard", "regex", "all_branches"])
4183
+ .optional()
4184
+ .describe("How push_events_branch_filter is interpreted"),
4185
+ issues_events: z.boolean().optional().describe("Trigger on issue events"),
4186
+ confidential_issues_events: z
4187
+ .boolean()
4188
+ .optional()
4189
+ .describe("Trigger on confidential issue events"),
4190
+ merge_requests_events: z.boolean().optional().describe("Trigger on merge request events"),
4191
+ tag_push_events: z.boolean().optional().describe("Trigger on tag push events"),
4192
+ note_events: z.boolean().optional().describe("Trigger on note/comment events"),
4193
+ confidential_note_events: z
4194
+ .boolean()
4195
+ .optional()
4196
+ .describe("Trigger on confidential note events"),
4197
+ job_events: z.boolean().optional().describe("Trigger on job events"),
4198
+ pipeline_events: z.boolean().optional().describe("Trigger on pipeline events"),
4199
+ wiki_page_events: z.boolean().optional().describe("Trigger on wiki page events"),
4200
+ deployment_events: z.boolean().optional().describe("Trigger on deployment events"),
4201
+ feature_flag_events: z.boolean().optional().describe("Trigger on feature flag events"),
4202
+ releases_events: z.boolean().optional().describe("Trigger on release events"),
4203
+ milestone_events: z.boolean().optional().describe("Trigger on milestone events"),
4204
+ resource_access_token_events: z
4205
+ .boolean()
4206
+ .optional()
4207
+ .describe("Trigger on access token expiry events"),
4208
+ resource_deploy_token_events: z
4209
+ .boolean()
4210
+ .optional()
4211
+ .describe("Trigger on project deploy token expiry events (project webhooks)"),
4212
+ member_events: z.boolean().optional().describe("Trigger on member events (group webhooks)"),
4213
+ project_events: z.boolean().optional().describe("Trigger on project events (group webhooks)"),
4214
+ subgroup_events: z.boolean().optional().describe("Trigger on subgroup events (group webhooks)"),
4215
+ custom_webhook_template: z
4216
+ .string()
4217
+ .optional()
4218
+ .describe("Custom JSON payload template for the webhook"),
4219
+ custom_headers: z
4220
+ .array(WebhookCustomHeaderSchema)
4221
+ .optional()
4222
+ .describe("Custom HTTP headers sent with the webhook. Each item has key and value strings."),
4223
+ };
4224
+ export const CreateWebhookSchema = z
4225
+ .object({
4226
+ project_id: z.coerce
4227
+ .string()
4228
+ .optional()
4229
+ .describe("Project ID or URL-encoded path. Provide either project_id or group_id, not both."),
4230
+ group_id: z.coerce
4231
+ .string()
4232
+ .optional()
4233
+ .describe("Group ID or URL-encoded path. Provide either project_id or group_id, not both."),
4234
+ ...WebhookMutationFields,
4235
+ })
4236
+ .refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
4237
+ export const UpdateWebhookSchema = z
4238
+ .object({
4239
+ project_id: z.coerce
4240
+ .string()
4241
+ .optional()
4242
+ .describe("Project ID or URL-encoded path. Provide either project_id or group_id, not both."),
4243
+ group_id: z.coerce
4244
+ .string()
4245
+ .optional()
4246
+ .describe("Group ID or URL-encoded path. Provide either project_id or group_id, not both."),
4247
+ hook_id: z.coerce.number().describe("ID of the webhook to update"),
4248
+ ...WebhookMutationFields,
4249
+ })
4250
+ .refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
4251
+ export const DeleteWebhookSchema = z
4252
+ .object({
4253
+ project_id: z.coerce
4254
+ .string()
4255
+ .optional()
4256
+ .describe("Project ID or URL-encoded path. Provide either project_id or group_id, not both."),
4257
+ group_id: z.coerce
4258
+ .string()
4259
+ .optional()
4260
+ .describe("Group ID or URL-encoded path. Provide either project_id or group_id, not both."),
4261
+ hook_id: z.coerce.number().describe("ID of the webhook to delete"),
4262
+ })
4263
+ .refine(hasExactlyOneWebhookScope, EXACTLY_ONE_WEBHOOK_SCOPE);
4154
4264
  export const HealthCheckSchema = z.object({});
4155
4265
  // --- CI/CD Variable types ---
4156
4266
  export const GitLabCiVariableSchema = z.object({
@@ -101,7 +101,7 @@ const GROUP_META = {
101
101
  },
102
102
  webhooks: {
103
103
  title: "Webhooks",
104
- blurb: "List webhooks configured on projects or groups, and inspect recent webhook events.",
104
+ blurb: "Create, update, and delete project or group webhooks, and inspect recent webhook events.",
105
105
  },
106
106
  search: {
107
107
  title: "Search",
@@ -134,5 +134,16 @@ describe('NO_PROXY Pattern Matching', () => {
134
134
  assert.strictEqual(agent1.constructor.name, 'Agent', 'Should match case-insensitively (lowercase)');
135
135
  assert.strictEqual(agent2.constructor.name, 'Agent', 'Should match case-insensitively (uppercase)');
136
136
  });
137
+ test('should re-evaluate NO_PROXY for redirect origins', () => {
138
+ const pool = new GitLabClientPool({
139
+ httpProxy: 'http://proxy.example.com:8080',
140
+ httpsProxy: 'http://proxy.example.com:8080',
141
+ noProxy: 'gitlab.internal.com',
142
+ });
143
+ const proxyToBypass = pool.getDispatcherForOrigin('https://gitlab.external.com/api/v4', 'https://gitlab.internal.com/api/v4');
144
+ const bypassToProxy = pool.getDispatcherForOrigin('https://gitlab.internal.com/api/v4', 'https://gitlab.external.com/api/v4');
145
+ assert.strictEqual(proxyToBypass.constructor.name, 'Agent', 'redirect onto NO_PROXY host should bypass');
146
+ assert.notStrictEqual(bypassToProxy.constructor.name, 'Agent', 'redirect onto proxied host should use proxy');
147
+ });
137
148
  });
138
149
  console.log('✅ NO_PROXY tests completed');
@@ -4,7 +4,6 @@
4
4
  */
5
5
  import { describe, test, after, before } from 'node:test';
6
6
  import assert from 'node:assert';
7
- import fetch from 'node-fetch';
8
7
  import { launchServer, findAvailablePort, cleanupServers, TransportMode, checkHealthEndpoint, HOST } from './utils/server-launcher.js';
9
8
  console.log('🔐 Remote Authorization Tests');
10
9
  console.log('');
@@ -6,20 +6,18 @@
6
6
  */
7
7
  import { describe, test } from "node:test";
8
8
  import assert from "node:assert";
9
- import { Headers, Response } from "node-fetch";
9
+ import { Readable } from "node:stream";
10
+ import { Response } from "undici";
10
11
  import { headersToPlainObject, isNonReplayableBody, wrapWithAuthRetry, } from "../auth-retry.js";
11
- // ---------------------------------------------------------------------------
12
- // Helpers
13
- // ---------------------------------------------------------------------------
14
12
  function mockFetch(status) {
15
- return (async () => new Response("", { status }));
13
+ return async () => new Response("", { status });
16
14
  }
17
15
  function mockFetchThenRetry() {
18
16
  let callCount = 0;
19
- return (async () => {
17
+ return async () => {
20
18
  callCount++;
21
19
  return new Response("", { status: callCount === 1 ? 401 : 200 });
22
- });
20
+ };
23
21
  }
24
22
  function makeConfig(overrides) {
25
23
  return {
@@ -30,9 +28,6 @@ function makeConfig(overrides) {
30
28
  ...overrides,
31
29
  };
32
30
  }
33
- // ---------------------------------------------------------------------------
34
- // headersToPlainObject
35
- // ---------------------------------------------------------------------------
36
31
  describe("headersToPlainObject", () => {
37
32
  test("null returns empty object", () => {
38
33
  assert.deepStrictEqual(headersToPlainObject(null), {});
@@ -63,9 +58,6 @@ describe("headersToPlainObject", () => {
63
58
  });
64
59
  });
65
60
  });
66
- // ---------------------------------------------------------------------------
67
- // isNonReplayableBody
68
- // ---------------------------------------------------------------------------
69
61
  describe("isNonReplayableBody", () => {
70
62
  test("null returns false", () => {
71
63
  assert.strictEqual(isNonReplayableBody(null), false);
@@ -85,16 +77,15 @@ describe("isNonReplayableBody", () => {
85
77
  test("object with .read() returns true (stream-like)", () => {
86
78
  assert.strictEqual(isNonReplayableBody({ read: () => { } }), true);
87
79
  });
88
- test("object with .getBuffer() and .getBoundary() returns true (FormData-like)", () => {
89
- assert.strictEqual(isNonReplayableBody({ getBuffer: () => { }, getBoundary: () => { } }), true);
90
- });
91
- test("object with only .getBuffer() (no .getBoundary()) returns false", () => {
92
- assert.strictEqual(isNonReplayableBody({ getBuffer: () => { } }), false);
80
+ test("Web ReadableStream returns true", () => {
81
+ const stream = new ReadableStream({
82
+ start(controller) {
83
+ controller.close();
84
+ },
85
+ });
86
+ assert.strictEqual(isNonReplayableBody(stream), true);
93
87
  });
94
88
  });
95
- // ---------------------------------------------------------------------------
96
- // wrapWithAuthRetry
97
- // ---------------------------------------------------------------------------
98
89
  describe("wrapWithAuthRetry", () => {
99
90
  test("non-401 response passes through unchanged", async () => {
100
91
  const wrapped = wrapWithAuthRetry(mockFetch(200), makeConfig());
@@ -137,7 +128,29 @@ describe("wrapWithAuthRetry", () => {
137
128
  });
138
129
  const wrapped = wrapWithAuthRetry(mockFetch(401), config);
139
130
  const res = await wrapped("http://example.com", {
140
- body: { pipe: () => { } }, // stream-like
131
+ body: Readable.from([]),
132
+ duplex: "half",
133
+ });
134
+ assert.strictEqual(res.status, 401);
135
+ assert.strictEqual(refreshCalled, false);
136
+ });
137
+ test("401 with Web ReadableStream body skips retry", async () => {
138
+ let refreshCalled = false;
139
+ const config = makeConfig({
140
+ refreshToken: async () => {
141
+ refreshCalled = true;
142
+ return "tok";
143
+ },
144
+ });
145
+ const stream = new ReadableStream({
146
+ start(controller) {
147
+ controller.close();
148
+ },
149
+ });
150
+ const wrapped = wrapWithAuthRetry(mockFetch(401), config);
151
+ const res = await wrapped("http://example.com", {
152
+ body: stream,
153
+ duplex: "half",
141
154
  });
142
155
  assert.strictEqual(res.status, 401);
143
156
  assert.strictEqual(refreshCalled, false);
@@ -148,27 +161,21 @@ describe("wrapWithAuthRetry", () => {
148
161
  const config = makeConfig({
149
162
  refreshToken: () => {
150
163
  refreshCount++;
151
- return new Promise((resolve) => {
164
+ return new Promise(resolve => {
152
165
  resolveRefresh = resolve;
153
166
  });
154
167
  },
155
168
  buildAuthHeaders: () => ({ Authorization: "Bearer stamped" }),
156
169
  });
157
- // Each call returns 401 first, then 200 on retry
158
170
  let callCount = 0;
159
- const base = (async () => {
171
+ const base = async () => {
160
172
  callCount++;
161
- // First two calls are the initial requests (both 401)
162
- // Next two are the retries (both 200)
163
173
  return new Response("", { status: callCount <= 2 ? 401 : 200 });
164
- });
174
+ };
165
175
  const wrapped = wrapWithAuthRetry(base, config);
166
- // Fire two concurrent requests
167
176
  const p1 = wrapped("http://example.com/a");
168
177
  const p2 = wrapped("http://example.com/b");
169
- // Wait a tick for both to hit the refresh path
170
- await new Promise((r) => setTimeout(r, 10));
171
- // Resolve the single pending refresh
178
+ await new Promise(r => setTimeout(r, 10));
172
179
  resolveRefresh("stamped-token");
173
180
  const [r1, r2] = await Promise.all([p1, p2]);
174
181
  assert.strictEqual(r1.status, 200);
@@ -58,6 +58,7 @@ const DELETE_SAMPLE_TOOLS = [
58
58
  "delete_milestone",
59
59
  "delete_group_milestone",
60
60
  "delete_project_variable",
61
+ "delete_webhook",
61
62
  ];
62
63
  const MODIFY_SAMPLE_TOOLS = [
63
64
  "create_issue",
@@ -31,7 +31,7 @@ const TOOLSET_TOOL_COUNTS = {
31
31
  users: 7,
32
32
  search: 3,
33
33
  workitems: 18,
34
- webhooks: 3,
34
+ webhooks: 6,
35
35
  groups: 1,
36
36
  variables: 10,
37
37
  dependency_proxy: 4,
@@ -83,7 +83,7 @@ const TOOLSET_SAMPLE_TOOLS = {
83
83
  tags: ["list_tags", "create_tag", "get_tag_signature"],
84
84
  users: ["get_users", "upload_markdown", "download_attachment"],
85
85
  search: ["search_code", "search_project_code", "search_group_code"],
86
- webhooks: ["list_webhooks", "list_webhook_events", "get_webhook_event"],
86
+ webhooks: ["list_webhooks", "create_webhook", "update_webhook", "delete_webhook", "list_webhook_events", "get_webhook_event"],
87
87
  groups: ["create_group"],
88
88
  variables: ["list_project_variables", "create_project_variable", "delete_project_variable", "list_group_variables", "create_group_variable", "delete_group_variable"],
89
89
  dependency_proxy: ["get_dependency_proxy_settings", "list_dependency_proxy_blobs", "purge_dependency_proxy_cache"],