@zereight/mcp-gitlab 2.1.0 → 2.1.1

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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Pure helper functions for OAuth 401 auto-retry logic.
3
+ *
4
+ * Extracted into a separate module so they can be unit-tested without
5
+ * importing index.ts (which has heavy side effects: starts the MCP server,
6
+ * reads env vars, etc.).
7
+ */
8
+ import { Headers } from "node-fetch";
9
+ /**
10
+ * Convert various header representations to a plain Record<string, string>.
11
+ */
12
+ export function headersToPlainObject(headers) {
13
+ if (!headers)
14
+ return {};
15
+ if (headers instanceof Headers) {
16
+ const obj = {};
17
+ headers.forEach((value, key) => { obj[key] = value; });
18
+ return obj;
19
+ }
20
+ if (Array.isArray(headers)) {
21
+ return Object.fromEntries(headers);
22
+ }
23
+ return headers;
24
+ }
25
+ /**
26
+ * Detect request bodies that cannot be replayed (streams, FormData).
27
+ */
28
+ export function isNonReplayableBody(body) {
29
+ if (!body)
30
+ return false;
31
+ // Stream-like objects (has .pipe or .read)
32
+ if (typeof body.pipe === "function" || typeof body.read === "function")
33
+ return true;
34
+ // form-data instances (duck-type check since FormData is dynamically imported)
35
+ if (typeof body.getBuffer === "function" && typeof body.getBoundary === "function")
36
+ return true;
37
+ return false;
38
+ }
39
+ /**
40
+ * Wrap a fetch function with automatic OAuth token refresh on 401 responses.
41
+ * On a 401, force-refreshes the OAuth token and retries the request once.
42
+ * The retry calls baseFetch directly (not the wrapper), so infinite loops are impossible.
43
+ * In non-OAuth mode, the wrapper is a transparent pass-through.
44
+ *
45
+ * When called without `config`, falls back to module globals in index.ts.
46
+ * When called with `config` (tests), uses injected dependencies.
47
+ */
48
+ export function wrapWithAuthRetry(baseFetch, config) {
49
+ let refreshLock = null;
50
+ const log = config.logger ?? { info: () => { }, error: () => { } };
51
+ return (async (url, options) => {
52
+ const response = await baseFetch(url, options);
53
+ if (response.status === 401 && config.isOAuthEnabled()) {
54
+ // Skip retry for non-replayable bodies (streams, FormData) since the first request consumed them
55
+ if (isNonReplayableBody(options?.body)) {
56
+ log.info("Received 401 but request body is not replayable (stream/FormData), skipping retry.");
57
+ return response;
58
+ }
59
+ log.info("Received 401, force-refreshing OAuth token and retrying...");
60
+ try {
61
+ // Mutex: coalesce concurrent refresh attempts into a single in-flight request
62
+ if (!refreshLock) {
63
+ refreshLock = config.refreshToken(true).finally(() => {
64
+ refreshLock = null;
65
+ });
66
+ }
67
+ const token = await refreshLock;
68
+ config.onTokenRefreshed(token);
69
+ const retryOptions = {
70
+ ...options,
71
+ headers: { ...headersToPlainObject(options?.headers), ...config.buildAuthHeaders() },
72
+ };
73
+ return await baseFetch(url, retryOptions);
74
+ }
75
+ catch (refreshError) {
76
+ log.error("OAuth token refresh failed, returning original 401 response:", refreshError);
77
+ }
78
+ }
79
+ return response;
80
+ });
81
+ }
package/build/index.js CHANGED
@@ -23,8 +23,8 @@ import { estimateMergeCommitCount, filterDiffsByPatterns, summarizeWebhookEvents
23
23
  import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js";
24
24
  import { GitLabClientPool } from "./gitlab-client-pool.js";
25
25
  import { allTools, readOnlyTools, destructiveTools, parseEnabledToolsets, parseIndividualTools, buildFeatureFlagOverrides, isToolInEnabledToolset, TOOLSET_DEFINITIONS, ALL_TOOLSET_IDS, } from "./tools/registry.js";
26
- import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, // Added
27
- CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema,
26
+ import { BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, // Added
27
+ CreateMergeRequestNoteSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateMergeRequestNoteEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateRepositorySchema, CreateWikiPageSchema, CreateGroupWikiPageSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteProjectMilestoneSchema, DeleteWikiPageSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, EditProjectMilestoneSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDraftNoteSchema, GetFileContentsSchema, GetIssueLinkSchema, GetIssueSchema, GetLabelSchema, GetMergeRequestDiffsSchema, GetMergeRequestSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetDeploymentSchema, GetEnvironmentSchema, GetNamespaceSchema,
28
28
  // pipeline job schemas
29
29
  GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectMilestoneSchema, GetProjectSchema, GetRepositoryTreeSchema, GetUsersSchema, GetWikiPageSchema, GitLabCommitSchema, GitLabCompareResultSchema, GitLabContentSchema, GitLabCreateUpdateFileResponseSchema, GitLabDiffSchema,
30
30
  // Discussion Schemas
@@ -32,7 +32,7 @@ GitLabDiscussionNoteSchema, // Added
32
32
  GitLabDiscussionSchema,
33
33
  // Draft Notes Schemas
34
34
  GitLabDraftNoteSchema, GitLabForkSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
35
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, ListWebhookEventsSchema, GetWebhookEventSchema, } from "./schemas.js";
35
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, 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, } from "./schemas.js";
36
36
  import { randomUUID } from "node:crypto";
37
37
  import { pino } from "pino";
38
38
  const logger = pino({
@@ -546,9 +546,22 @@ const createCookieJar = async () => {
546
546
  }
547
547
  return jar;
548
548
  };
549
+ // Auth retry helpers — extracted to auth-retry.ts for testability (no side effects)
550
+ export { headersToPlainObject, isNonReplayableBody, wrapWithAuthRetry, } from "./auth-retry.js";
551
+ import { wrapWithAuthRetry } from "./auth-retry.js";
552
+ /** Build AuthRetryConfig from module globals (lazy — reads globals at call time). */
553
+ function defaultAuthRetryConfig() {
554
+ return {
555
+ isOAuthEnabled: () => USE_OAUTH && oauthClient != null,
556
+ refreshToken: (force) => oauthClient.getAccessToken(force),
557
+ onTokenRefreshed: (token) => { OAUTH_ACCESS_TOKEN = token; },
558
+ buildAuthHeaders,
559
+ logger,
560
+ };
561
+ }
549
562
  // Cookie jar and fetch - reloaded when cookie file changes
550
563
  let cookieJar = null;
551
- let fetch = nodeFetch;
564
+ let fetch = wrapWithAuthRetry(nodeFetch, defaultAuthRetryConfig());
552
565
  let lastCookieMtime = 0;
553
566
  let cookieReloadLock = null; // Mutex to prevent parallel reloads
554
567
  // Auth proxies may redirect and set cookies on the first request. We make a throwaway
@@ -568,7 +581,7 @@ async function reloadCookiesIfChanged() {
568
581
  lastCookieMtime = mtime;
569
582
  const newJar = await createCookieJar();
570
583
  cookieJar = newJar;
571
- fetch = newJar ? fetchCookie(nodeFetch, newJar) : nodeFetch;
584
+ fetch = wrapWithAuthRetry(newJar ? fetchCookie(nodeFetch, newJar) : nodeFetch, defaultAuthRetryConfig());
572
585
  initialSessionRequestMade = false;
573
586
  }
574
587
  }
@@ -577,7 +590,7 @@ async function reloadCookiesIfChanged() {
577
590
  if (cookieJar) {
578
591
  logger.info("Cookie file removed, clearing cached cookies");
579
592
  cookieJar = null;
580
- fetch = nodeFetch;
593
+ fetch = wrapWithAuthRetry(nodeFetch, defaultAuthRetryConfig());
581
594
  lastCookieMtime = 0;
582
595
  initialSessionRequestMade = false;
583
596
  }
@@ -1477,6 +1490,39 @@ async function createWorkItemNote(projectId, iid, body, options = {}) {
1477
1490
  }
1478
1491
  return data.createNote.note;
1479
1492
  }
1493
+ // --- Emoji Reactions (GraphQL) ---
1494
+ async function addGraphQLAwardEmoji(awardableId, name) {
1495
+ const data = await executeGraphQL(`mutation($awardableId: AwardableID!, $name: String!) {
1496
+ awardEmojiAdd(input: { awardableId: $awardableId, name: $name }) {
1497
+ awardEmoji { name user { username } }
1498
+ errors
1499
+ }
1500
+ }`, { awardableId, name });
1501
+ if (data.awardEmojiAdd.errors?.length > 0) {
1502
+ throw new Error(`Failed to add emoji reaction: ${data.awardEmojiAdd.errors.join(", ")}`);
1503
+ }
1504
+ return data.awardEmojiAdd.awardEmoji;
1505
+ }
1506
+ async function listGraphQLAwardEmoji(awardableId) {
1507
+ const data = await executeGraphQL(`query($id: AwardableID!) {
1508
+ awardable(id: $id) {
1509
+ awardEmoji { nodes { name user { username } } }
1510
+ }
1511
+ }`, { id: awardableId });
1512
+ return data.awardable?.awardEmoji?.nodes ?? [];
1513
+ }
1514
+ async function removeGraphQLAwardEmoji(awardableId, name) {
1515
+ const data = await executeGraphQL(`mutation($awardableId: AwardableID!, $name: String!) {
1516
+ awardEmojiRemove(input: { awardableId: $awardableId, name: $name }) {
1517
+ awardEmoji { name }
1518
+ errors
1519
+ }
1520
+ }`, { awardableId, name });
1521
+ if (data.awardEmojiRemove.errors?.length > 0) {
1522
+ throw new Error(`Failed to remove emoji reaction: ${data.awardEmojiRemove.errors.join(", ")}`);
1523
+ }
1524
+ return data.awardEmojiRemove.awardEmoji;
1525
+ }
1480
1526
  // --- Incident Timeline Events ---
1481
1527
  /**
1482
1528
  * List timeline events for an incident.
@@ -2695,6 +2741,42 @@ async function deleteMergeRequestNote(projectId, mergeRequestIid, noteId) {
2695
2741
  throw new Error(`GitLab API error: ${response.status} ${response.statusText}\n${errorText}`);
2696
2742
  }
2697
2743
  }
2744
+ // --- Emoji Reactions (REST) ---
2745
+ function buildAwardEmojiPath(entity, projectId, entityIid, opts) {
2746
+ projectId = decodeURIComponent(projectId);
2747
+ const pp = encodeURIComponent(getEffectiveProjectId(projectId));
2748
+ let path = `${getEffectiveApiUrl()}/projects/${pp}/${entity}/${entityIid}`;
2749
+ if (opts?.noteId) {
2750
+ path = opts.discussionId
2751
+ ? `${path}/discussions/${opts.discussionId}/notes/${opts.noteId}`
2752
+ : `${path}/notes/${opts.noteId}`;
2753
+ }
2754
+ path += "/award_emoji";
2755
+ if (opts?.awardId)
2756
+ path += `/${opts.awardId}`;
2757
+ return path;
2758
+ }
2759
+ async function createRestAwardEmoji(path, name) {
2760
+ const response = await fetch(path, {
2761
+ ...getFetchConfig(),
2762
+ method: "POST",
2763
+ body: JSON.stringify({ name }),
2764
+ });
2765
+ await handleGitLabError(response);
2766
+ return response.json();
2767
+ }
2768
+ async function listRestAwardEmoji(path) {
2769
+ const response = await fetch(path, getFetchConfig());
2770
+ await handleGitLabError(response);
2771
+ return response.json();
2772
+ }
2773
+ async function deleteRestAwardEmoji(path) {
2774
+ const response = await fetch(path, {
2775
+ ...getFetchConfig(),
2776
+ method: "DELETE",
2777
+ });
2778
+ await handleGitLabError(response);
2779
+ }
2698
2780
  async function getMergeRequestNote(projectId, mergeRequestIid, noteId) {
2699
2781
  projectId = decodeURIComponent(projectId); // Decode project ID
2700
2782
  const url = new URL(`${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/merge_requests/${mergeRequestIid}/notes/${noteId}`);
@@ -4081,6 +4163,8 @@ async function listGroupProjects(options) {
4081
4163
  url.searchParams.append("with_custom_attributes", options.with_custom_attributes.toString());
4082
4164
  if (options.with_security_reports !== undefined)
4083
4165
  url.searchParams.append("with_security_reports", options.with_security_reports.toString());
4166
+ if (options.topic)
4167
+ url.searchParams.append("topic", options.topic);
4084
4168
  const response = await fetch(url.toString(), {
4085
4169
  ...getFetchConfig(),
4086
4170
  });
@@ -5773,6 +5857,42 @@ async function handleToolCall(params) {
5773
5857
  content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
5774
5858
  };
5775
5859
  }
5860
+ case "list_merge_request_emoji_reactions": {
5861
+ const args = ListMergeRequestEmojiReactionsSchema.parse(params.arguments);
5862
+ const path = buildAwardEmojiPath("merge_requests", args.project_id, args.merge_request_iid);
5863
+ const result = await listRestAwardEmoji(path);
5864
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
5865
+ }
5866
+ case "list_merge_request_note_emoji_reactions": {
5867
+ const args = ListMergeRequestNoteEmojiReactionsSchema.parse(params.arguments);
5868
+ const path = buildAwardEmojiPath("merge_requests", args.project_id, args.merge_request_iid, { noteId: args.note_id, discussionId: args.discussion_id });
5869
+ const result = await listRestAwardEmoji(path);
5870
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
5871
+ }
5872
+ case "create_merge_request_emoji_reaction": {
5873
+ const args = CreateMergeRequestEmojiReactionSchema.parse(params.arguments);
5874
+ const path = buildAwardEmojiPath("merge_requests", args.project_id, args.merge_request_iid);
5875
+ const result = await createRestAwardEmoji(path, args.name);
5876
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
5877
+ }
5878
+ case "delete_merge_request_emoji_reaction": {
5879
+ const args = DeleteMergeRequestEmojiReactionSchema.parse(params.arguments);
5880
+ const path = buildAwardEmojiPath("merge_requests", args.project_id, args.merge_request_iid, { awardId: args.award_id });
5881
+ await deleteRestAwardEmoji(path);
5882
+ return { content: [{ type: "text", text: "Merge request emoji reaction deleted successfully" }] };
5883
+ }
5884
+ case "create_merge_request_note_emoji_reaction": {
5885
+ const args = CreateMergeRequestNoteEmojiReactionSchema.parse(params.arguments);
5886
+ const path = buildAwardEmojiPath("merge_requests", args.project_id, args.merge_request_iid, { noteId: args.note_id, discussionId: args.discussion_id });
5887
+ const result = await createRestAwardEmoji(path, args.name);
5888
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
5889
+ }
5890
+ case "delete_merge_request_note_emoji_reaction": {
5891
+ const args = DeleteMergeRequestNoteEmojiReactionSchema.parse(params.arguments);
5892
+ const path = buildAwardEmojiPath("merge_requests", args.project_id, args.merge_request_iid, { noteId: args.note_id, discussionId: args.discussion_id, awardId: args.award_id });
5893
+ await deleteRestAwardEmoji(path);
5894
+ return { content: [{ type: "text", text: "Merge request note emoji reaction deleted successfully" }] };
5895
+ }
5776
5896
  case "update_issue_note": {
5777
5897
  const args = UpdateIssueNoteSchema.parse(params.arguments);
5778
5898
  const note = await updateIssueNote(args.project_id, args.issue_iid, args.discussion_id, args.note_id, args.body, args.resolved);
@@ -5787,6 +5907,42 @@ async function handleToolCall(params) {
5787
5907
  content: [{ type: "text", text: JSON.stringify(note, null, 2) }],
5788
5908
  };
5789
5909
  }
5910
+ case "list_issue_emoji_reactions": {
5911
+ const args = ListIssueEmojiReactionsSchema.parse(params.arguments);
5912
+ const path = buildAwardEmojiPath("issues", args.project_id, args.issue_iid);
5913
+ const result = await listRestAwardEmoji(path);
5914
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
5915
+ }
5916
+ case "list_issue_note_emoji_reactions": {
5917
+ const args = ListIssueNoteEmojiReactionsSchema.parse(params.arguments);
5918
+ const path = buildAwardEmojiPath("issues", args.project_id, args.issue_iid, { noteId: args.note_id, discussionId: args.discussion_id });
5919
+ const result = await listRestAwardEmoji(path);
5920
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
5921
+ }
5922
+ case "create_issue_emoji_reaction": {
5923
+ const args = CreateIssueEmojiReactionSchema.parse(params.arguments);
5924
+ const path = buildAwardEmojiPath("issues", args.project_id, args.issue_iid);
5925
+ const result = await createRestAwardEmoji(path, args.name);
5926
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
5927
+ }
5928
+ case "delete_issue_emoji_reaction": {
5929
+ const args = DeleteIssueEmojiReactionSchema.parse(params.arguments);
5930
+ const path = buildAwardEmojiPath("issues", args.project_id, args.issue_iid, { awardId: args.award_id });
5931
+ await deleteRestAwardEmoji(path);
5932
+ return { content: [{ type: "text", text: "Issue emoji reaction deleted successfully" }] };
5933
+ }
5934
+ case "create_issue_note_emoji_reaction": {
5935
+ const args = CreateIssueNoteEmojiReactionSchema.parse(params.arguments);
5936
+ const path = buildAwardEmojiPath("issues", args.project_id, args.issue_iid, { noteId: args.note_id, discussionId: args.discussion_id });
5937
+ const result = await createRestAwardEmoji(path, args.name);
5938
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
5939
+ }
5940
+ case "delete_issue_note_emoji_reaction": {
5941
+ const args = DeleteIssueNoteEmojiReactionSchema.parse(params.arguments);
5942
+ const path = buildAwardEmojiPath("issues", args.project_id, args.issue_iid, { noteId: args.note_id, discussionId: args.discussion_id, awardId: args.award_id });
5943
+ await deleteRestAwardEmoji(path);
5944
+ return { content: [{ type: "text", text: "Issue note emoji reaction deleted successfully" }] };
5945
+ }
5790
5946
  case "get_merge_request": {
5791
5947
  const args = GetMergeRequestSchema.parse(params.arguments);
5792
5948
  const mergeRequest = await getMergeRequest(args.project_id, args.merge_request_iid, args.source_branch);
@@ -6236,6 +6392,39 @@ async function handleToolCall(params) {
6236
6392
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
6237
6393
  };
6238
6394
  }
6395
+ case "list_work_item_emoji_reactions": {
6396
+ const args = ListWorkItemEmojiReactionsSchema.parse(params.arguments);
6397
+ const { workItemGID } = await resolveWorkItemGID(args.project_id, args.iid);
6398
+ const result = await listGraphQLAwardEmoji(workItemGID);
6399
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
6400
+ }
6401
+ case "list_work_item_note_emoji_reactions": {
6402
+ const args = ListWorkItemNoteEmojiReactionsSchema.parse(params.arguments);
6403
+ const result = await listGraphQLAwardEmoji(args.note_id);
6404
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
6405
+ }
6406
+ case "create_work_item_emoji_reaction": {
6407
+ const args = CreateWorkItemEmojiReactionSchema.parse(params.arguments);
6408
+ const { workItemGID } = await resolveWorkItemGID(args.project_id, args.iid);
6409
+ const result = await addGraphQLAwardEmoji(workItemGID, args.name);
6410
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
6411
+ }
6412
+ case "delete_work_item_emoji_reaction": {
6413
+ const args = DeleteWorkItemEmojiReactionSchema.parse(params.arguments);
6414
+ const { workItemGID } = await resolveWorkItemGID(args.project_id, args.iid);
6415
+ const result = await removeGraphQLAwardEmoji(workItemGID, args.name);
6416
+ return { content: [{ type: "text", text: JSON.stringify(result ?? { status: "success", message: "Work item emoji reaction removed" }, null, 2) }] };
6417
+ }
6418
+ case "create_work_item_note_emoji_reaction": {
6419
+ const args = CreateWorkItemNoteEmojiReactionSchema.parse(params.arguments);
6420
+ const result = await addGraphQLAwardEmoji(args.note_id, args.name);
6421
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
6422
+ }
6423
+ case "delete_work_item_note_emoji_reaction": {
6424
+ const args = DeleteWorkItemNoteEmojiReactionSchema.parse(params.arguments);
6425
+ const result = await removeGraphQLAwardEmoji(args.note_id, args.name);
6426
+ return { content: [{ type: "text", text: JSON.stringify(result ?? { status: "success", message: "Work item note emoji reaction removed" }, null, 2) }] };
6427
+ }
6239
6428
  case "get_timeline_events": {
6240
6429
  const args = GetTimelineEventsSchema.parse(params.arguments);
6241
6430
  const result = await getTimelineEvents(args.project_id, args.incident_iid);
@@ -7171,14 +7360,54 @@ async function startStreamableHTTPServer() {
7171
7360
  const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
7172
7361
  const issuerUrl = new URL(MCP_SERVER_URL);
7173
7362
  const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES);
7174
- // Mounts /.well-known/oauth-authorization-server,
7175
- // /.well-known/oauth-protected-resource,
7176
- // /authorize, /token, /register, /revoke
7363
+ const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
7364
+ // When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
7365
+ // advertises root-level endpoints. Override to use path-prefixed endpoints.
7366
+ const issuerPath = issuerUrl.pathname.replace(/\/$/, "");
7367
+ if (issuerPath) {
7368
+ const routedBaseUrl = `${issuerUrl.origin}${issuerPath}`;
7369
+ const authorizationServerMetadata = {
7370
+ issuer: issuerUrl.href,
7371
+ authorization_endpoint: `${routedBaseUrl}/authorize`,
7372
+ token_endpoint: `${routedBaseUrl}/token`,
7373
+ registration_endpoint: `${routedBaseUrl}/register`,
7374
+ revocation_endpoint: `${routedBaseUrl}/revoke`,
7375
+ response_types_supported: ["code"],
7376
+ code_challenge_methods_supported: ["S256"],
7377
+ token_endpoint_auth_methods_supported: ["client_secret_post", "none"],
7378
+ grant_types_supported: ["authorization_code", "refresh_token"],
7379
+ scopes_supported: scopesSupported,
7380
+ revocation_endpoint_auth_methods_supported: ["client_secret_post"],
7381
+ };
7382
+ const protectedResourceMetadata = {
7383
+ resource: issuerUrl.href,
7384
+ authorization_servers: [issuerUrl.href],
7385
+ scopes_supported: scopesSupported,
7386
+ resource_name: "GitLab MCP Server",
7387
+ };
7388
+ const authorizationMetadataRoutes = [
7389
+ "/.well-known/oauth-authorization-server",
7390
+ "/.well-known/oauth-authorization-server/*path",
7391
+ ];
7392
+ const protectedResourceRoutes = [
7393
+ "/.well-known/oauth-protected-resource",
7394
+ "/.well-known/oauth-protected-resource/*path",
7395
+ ];
7396
+ app.get(authorizationMetadataRoutes, (_req, res) => {
7397
+ res.json(authorizationServerMetadata);
7398
+ });
7399
+ app.get(protectedResourceRoutes, (_req, res) => {
7400
+ res.json(protectedResourceMetadata);
7401
+ });
7402
+ logger.info({ issuerPath }, "Serving path-aware OAuth metadata for reverse-proxy deployments");
7403
+ }
7404
+ // Mounts /.well-known/oauth-authorization-server (shadowed above when basePath set),
7405
+ // /.well-known/oauth-protected-resource, /authorize, /token, /register, /revoke
7177
7406
  app.use(mcpAuthRouter({
7178
7407
  provider: oauthProvider,
7179
7408
  issuerUrl,
7180
7409
  baseUrl: issuerUrl,
7181
- scopesSupported: GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"],
7410
+ scopesSupported,
7182
7411
  resourceName: "GitLab MCP Server",
7183
7412
  }));
7184
7413
  // Expose provider so the /mcp route middleware can reference it
package/build/oauth.js CHANGED
@@ -459,15 +459,15 @@ export class GitLabOAuth {
459
459
  /**
460
460
  * Get a valid access token, refreshing if necessary
461
461
  */
462
- async getAccessToken() {
462
+ async getAccessToken(force = false) {
463
463
  let tokenData = this.loadToken();
464
- // If no token or expired, start OAuth flow or refresh
464
+ // If no token or expired (or forced), start OAuth flow or refresh
465
465
  if (!tokenData) {
466
466
  logger.info("No stored token found. Starting OAuth flow...");
467
467
  tokenData = await this.startOAuthFlow();
468
468
  }
469
- else if (this.isTokenExpired(tokenData)) {
470
- logger.info("Token expired. Refreshing...");
469
+ else if (force || this.isTokenExpired(tokenData)) {
470
+ logger.info(force && !this.isTokenExpired(tokenData) ? "Force-refreshing OAuth token..." : "Token expired. Refreshing...");
471
471
  if (tokenData.refresh_token) {
472
472
  try {
473
473
  tokenData = await this.refreshAccessToken(tokenData.refresh_token);
package/build/schemas.js CHANGED
@@ -1689,6 +1689,7 @@ export const ListProjectsSchema = z
1689
1689
  .optional()
1690
1690
  .describe("Filter projects with merge requests feature enabled"),
1691
1691
  min_access_level: z.coerce.number().optional().describe("Filter by minimum access level"),
1692
+ topic: z.string().optional().describe("Filter by topic (projects tagged with this topic)"),
1692
1693
  })
1693
1694
  .merge(PaginationOptionsSchema);
1694
1695
  // Label operation schemas
@@ -1760,6 +1761,7 @@ export const ListGroupProjectsSchema = z
1760
1761
  statistics: z.coerce.boolean().optional().describe("Include project statistics"),
1761
1762
  with_custom_attributes: z.coerce.boolean().optional().describe("Include custom attributes"),
1762
1763
  with_security_reports: z.coerce.boolean().optional().describe("Include security reports"),
1764
+ topic: z.string().optional().describe("Filter by topic (projects tagged with this topic)"),
1763
1765
  })
1764
1766
  .merge(PaginationOptionsSchema);
1765
1767
  // Add wiki operation schemas
@@ -2273,8 +2275,8 @@ export const GitLabEventSchema = z
2273
2275
  created_at: z.string(),
2274
2276
  author: GitLabEventAuthorSchema,
2275
2277
  author_username: z.string(),
2276
- imported: z.coerce.boolean(),
2277
- imported_from: z.string(),
2278
+ imported: z.coerce.boolean().optional(),
2279
+ imported_from: z.string().nullable().optional(),
2278
2280
  })
2279
2281
  .passthrough(); // Allow additional fields
2280
2282
  // List events schema
@@ -2711,6 +2713,98 @@ export const ListCustomFieldDefinitionsSchema = z.object({
2711
2713
  .default("issue")
2712
2714
  .describe("The work item type to list custom field definitions for. Defaults to 'issue'."),
2713
2715
  });
2716
+ // --- Emoji Reaction schemas (REST: MRs and Issues) ---
2717
+ const emojiNameField = z.string().describe("Name of the emoji without colons (e.g. 'thumbsup', 'rocket', 'eyes')");
2718
+ const awardIdField = z.coerce.string().describe("The ID of the emoji reaction to delete");
2719
+ const noteEmojiDiscussionField = z.coerce.string().optional().describe("The ID of a discussion thread. Required for notes that are discussion replies; omit for top-level notes.");
2720
+ export const CreateMergeRequestEmojiReactionSchema = ProjectParamsSchema.extend({
2721
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2722
+ name: emojiNameField,
2723
+ });
2724
+ export const DeleteMergeRequestEmojiReactionSchema = ProjectParamsSchema.extend({
2725
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2726
+ award_id: awardIdField,
2727
+ });
2728
+ export const CreateMergeRequestNoteEmojiReactionSchema = ProjectParamsSchema.extend({
2729
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2730
+ note_id: z.coerce.string().describe("The ID of a note (comment or thread reply)"),
2731
+ discussion_id: noteEmojiDiscussionField,
2732
+ name: emojiNameField,
2733
+ });
2734
+ export const DeleteMergeRequestNoteEmojiReactionSchema = ProjectParamsSchema.extend({
2735
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2736
+ note_id: z.coerce.string().describe("The ID of a note (comment or thread reply)"),
2737
+ discussion_id: noteEmojiDiscussionField,
2738
+ award_id: awardIdField,
2739
+ });
2740
+ export const CreateIssueEmojiReactionSchema = ProjectParamsSchema.extend({
2741
+ issue_iid: z.coerce.string().describe("The IID of an issue"),
2742
+ name: emojiNameField,
2743
+ });
2744
+ export const DeleteIssueEmojiReactionSchema = ProjectParamsSchema.extend({
2745
+ issue_iid: z.coerce.string().describe("The IID of an issue"),
2746
+ award_id: awardIdField,
2747
+ });
2748
+ export const CreateIssueNoteEmojiReactionSchema = ProjectParamsSchema.extend({
2749
+ issue_iid: z.coerce.string().describe("The IID of an issue"),
2750
+ note_id: z.coerce.string().describe("The ID of a note (comment or thread reply)"),
2751
+ discussion_id: noteEmojiDiscussionField,
2752
+ name: emojiNameField,
2753
+ });
2754
+ export const DeleteIssueNoteEmojiReactionSchema = ProjectParamsSchema.extend({
2755
+ issue_iid: z.coerce.string().describe("The IID of an issue"),
2756
+ note_id: z.coerce.string().describe("The ID of a note (comment or thread reply)"),
2757
+ discussion_id: noteEmojiDiscussionField,
2758
+ award_id: awardIdField,
2759
+ });
2760
+ // --- Emoji Reaction schemas (GraphQL: Work Items) ---
2761
+ export const CreateWorkItemEmojiReactionSchema = z.object({
2762
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2763
+ iid: z.coerce.number().describe("The internal ID of the work item"),
2764
+ name: emojiNameField,
2765
+ });
2766
+ export const DeleteWorkItemEmojiReactionSchema = z.object({
2767
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2768
+ iid: z.coerce.number().describe("The internal ID of the work item"),
2769
+ name: emojiNameField,
2770
+ });
2771
+ export const CreateWorkItemNoteEmojiReactionSchema = z.object({
2772
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2773
+ iid: z.coerce.number().describe("The internal ID of the work item"),
2774
+ note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
2775
+ name: emojiNameField,
2776
+ });
2777
+ export const DeleteWorkItemNoteEmojiReactionSchema = z.object({
2778
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2779
+ iid: z.coerce.number().describe("The internal ID of the work item"),
2780
+ note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
2781
+ name: emojiNameField,
2782
+ });
2783
+ export const ListMergeRequestEmojiReactionsSchema = ProjectParamsSchema.extend({
2784
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2785
+ });
2786
+ export const ListMergeRequestNoteEmojiReactionsSchema = ProjectParamsSchema.extend({
2787
+ merge_request_iid: z.coerce.string().describe("The IID of a merge request"),
2788
+ note_id: z.coerce.string().describe("The ID of a note (comment or thread reply)"),
2789
+ discussion_id: noteEmojiDiscussionField,
2790
+ });
2791
+ export const ListIssueEmojiReactionsSchema = ProjectParamsSchema.extend({
2792
+ issue_iid: z.coerce.string().describe("The IID of an issue"),
2793
+ });
2794
+ export const ListIssueNoteEmojiReactionsSchema = ProjectParamsSchema.extend({
2795
+ issue_iid: z.coerce.string().describe("The IID of an issue"),
2796
+ note_id: z.coerce.string().describe("The ID of a note (comment or thread reply)"),
2797
+ discussion_id: noteEmojiDiscussionField,
2798
+ });
2799
+ export const ListWorkItemEmojiReactionsSchema = z.object({
2800
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2801
+ iid: z.coerce.number().describe("The internal ID of the work item"),
2802
+ });
2803
+ export const ListWorkItemNoteEmojiReactionsSchema = z.object({
2804
+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2805
+ iid: z.coerce.number().describe("The internal ID of the work item"),
2806
+ note_id: z.string().describe("The GraphQL GID of the note (e.g. 'gid://gitlab/Note/123' from list_work_item_notes)"),
2807
+ });
2714
2808
  // --- Incident Timeline Event schemas ---
2715
2809
  export const GetTimelineEventsSchema = z.object({
2716
2810
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -139,6 +139,55 @@ describe("MCP OAuth — Discovery Endpoints", () => {
139
139
  assert.ok(body.resource, "Should have resource field");
140
140
  console.log(" ✓ Protected resource metadata returned");
141
141
  });
142
+ test("path-prefixed MCP_SERVER_URL serves path-aware discovery metadata", async () => {
143
+ const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 25);
144
+ const prefixedMockGitLab = new MockGitLabServer({
145
+ port: mockPort,
146
+ validTokens: [MOCK_OAUTH_TOKEN],
147
+ });
148
+ await prefixedMockGitLab.start();
149
+ const scopedServers = [];
150
+ try {
151
+ const mockGitLabUrl = prefixedMockGitLab.getUrl();
152
+ addOAuthEndpoints(prefixedMockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
153
+ const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 25);
154
+ const mcpBaseUrl = `http://${HOST}:${mcpPort}`;
155
+ const issuerPath = "/gitlab-mcp";
156
+ const prefixedServerUrl = `${mcpBaseUrl}${issuerPath}`;
157
+ const server = await launchServer({
158
+ mode: TransportMode.STREAMABLE_HTTP,
159
+ port: mcpPort,
160
+ timeout: 5000,
161
+ env: {
162
+ STREAMABLE_HTTP: "true",
163
+ GITLAB_MCP_OAUTH: "true",
164
+ GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
165
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
166
+ MCP_SERVER_URL: prefixedServerUrl,
167
+ MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
168
+ },
169
+ });
170
+ scopedServers.push(server);
171
+ const authMetadataRes = await fetch(`${mcpBaseUrl}/.well-known/oauth-authorization-server${issuerPath}`);
172
+ assert.strictEqual(authMetadataRes.status, 200, "Should return 200");
173
+ const authMetadata = (await authMetadataRes.json());
174
+ assert.strictEqual(authMetadata.issuer, prefixedServerUrl);
175
+ assert.strictEqual(authMetadata.authorization_endpoint, `${prefixedServerUrl}/authorize`);
176
+ assert.strictEqual(authMetadata.token_endpoint, `${prefixedServerUrl}/token`);
177
+ assert.strictEqual(authMetadata.registration_endpoint, `${prefixedServerUrl}/register`);
178
+ assert.strictEqual(authMetadata.revocation_endpoint, `${prefixedServerUrl}/revoke`);
179
+ const resourceMetadataRes = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource${issuerPath}/mcp`);
180
+ assert.strictEqual(resourceMetadataRes.status, 200, "Should return 200");
181
+ const resourceMetadata = (await resourceMetadataRes.json());
182
+ assert.strictEqual(resourceMetadata.resource, prefixedServerUrl);
183
+ assert.deepStrictEqual(resourceMetadata.authorization_servers, [prefixedServerUrl]);
184
+ console.log(" ✓ Path-prefixed discovery metadata returned at RFC well-known URLs");
185
+ }
186
+ finally {
187
+ cleanupServers(scopedServers);
188
+ await prefixedMockGitLab.stop();
189
+ }
190
+ });
142
191
  });
143
192
  // ---------------------------------------------------------------------------
144
193
  // Test suite: /mcp auth enforcement
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ts-node
2
- import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema, CreateIssueNoteSchema } from '../schemas.js';
2
+ import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema } from '../schemas.js';
3
3
  function runGetFileContentsSchemaTests() {
4
4
  console.log('🧪 Testing GetFileContentsSchema...');
5
5
  const cases = [
@@ -371,13 +371,69 @@ function runCreateIssueNoteSchemaTests() {
371
371
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
372
372
  return { passed, failed };
373
373
  }
374
+ function runEmojiReactionSchemaTests() {
375
+ console.log('\n🧪 Testing Emoji Reaction Schemas...');
376
+ const cases = [
377
+ { name: 'schema:create_mr_emoji:valid', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42', name: 'thumbsup' }, expected: { project_id: 'my/project', merge_request_iid: '42', name: 'thumbsup' } },
378
+ { name: 'schema:create_mr_emoji:numeric-iid-coerced', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: 42, name: 'rocket' }, expected: { merge_request_iid: '42', name: 'rocket' } },
379
+ { name: 'schema:create_mr_emoji:reject-missing-name', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42' }, shouldFail: true },
380
+ { name: 'schema:delete_mr_emoji:valid', schema: DeleteMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42', award_id: '123' }, expected: { merge_request_iid: '42', award_id: '123' } },
381
+ { name: 'schema:create_issue_emoji:valid', schema: CreateIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10', name: 'thumbsdown' }, expected: { issue_iid: '10', name: 'thumbsdown' } },
382
+ { name: 'schema:create_issue_emoji:reject-missing-name', schema: CreateIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10' }, shouldFail: true },
383
+ { name: 'schema:delete_issue_emoji:valid', schema: DeleteIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10', award_id: '99' }, expected: { issue_iid: '10', award_id: '99' } },
384
+ { name: 'schema:create_work_item_emoji:valid', schema: CreateWorkItemEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, name: 'rocket' }, expected: { iid: 5, name: 'rocket' } },
385
+ { name: 'schema:create_work_item_emoji:reject-missing-name', schema: CreateWorkItemEmojiReactionSchema, input: { project_id: 'my/project', iid: 5 }, shouldFail: true },
386
+ { name: 'schema:create_work_item_note_emoji:valid', schema: CreateWorkItemNoteEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, note_id: 'gid://gitlab/Note/123', name: 'thumbsup' }, expected: { iid: 5, note_id: 'gid://gitlab/Note/123', name: 'thumbsup' } },
387
+ { name: 'schema:create_work_item_note_emoji:reject-missing-note-id', schema: CreateWorkItemNoteEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, name: 'thumbsup' }, shouldFail: true },
388
+ ];
389
+ let passed = 0;
390
+ let failed = 0;
391
+ cases.forEach(testCase => {
392
+ const result = { name: testCase.name, status: 'failed' };
393
+ const parsed = testCase.schema.safeParse(testCase.input);
394
+ if (testCase.shouldFail) {
395
+ if (parsed.success) {
396
+ result.error = 'Expected schema validation to fail';
397
+ }
398
+ else {
399
+ result.status = 'passed';
400
+ }
401
+ }
402
+ else if (parsed.success) {
403
+ const expected = testCase.expected || {};
404
+ const matches = Object.entries(expected).every(([key, value]) => {
405
+ return parsed.data[key] === value;
406
+ });
407
+ if (matches) {
408
+ result.status = 'passed';
409
+ }
410
+ else {
411
+ result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
412
+ }
413
+ }
414
+ else {
415
+ result.error = parsed.error?.message || 'Schema validation failed';
416
+ }
417
+ if (result.status === 'passed') {
418
+ passed++;
419
+ console.log(`✅ ${result.name}`);
420
+ }
421
+ else {
422
+ failed++;
423
+ console.log(`❌ ${result.name}: ${result.error}`);
424
+ }
425
+ });
426
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
427
+ return { passed, failed };
428
+ }
374
429
  if (import.meta.url === `file://${process.argv[1]}`) {
375
430
  const getFileContentsResult = runGetFileContentsSchemaTests();
376
431
  const fileContentResult = runGitLabFileContentSchemaTests();
377
432
  const createPipelineResult = runCreatePipelineSchemaTests();
378
433
  const createIssueNoteResult = runCreateIssueNoteSchemaTests();
379
- const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed;
380
- const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed;
434
+ const emojiReactionResult = runEmojiReactionSchemaTests();
435
+ const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed + emojiReactionResult.passed;
436
+ const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed + emojiReactionResult.failed;
381
437
  console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
382
438
  if (totalFailed > 0) {
383
439
  process.exit(1);
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Auth Retry Tests
3
+ * Unit tests for headersToPlainObject, isNonReplayableBody, and wrapWithAuthRetry.
4
+ *
5
+ * These are pure-function / DI-based tests — no env vars or external services needed.
6
+ */
7
+ import { describe, test } from "node:test";
8
+ import assert from "node:assert";
9
+ import { Headers, Response } from "node-fetch";
10
+ import { headersToPlainObject, isNonReplayableBody, wrapWithAuthRetry, } from "../auth-retry.js";
11
+ // ---------------------------------------------------------------------------
12
+ // Helpers
13
+ // ---------------------------------------------------------------------------
14
+ function mockFetch(status) {
15
+ return (async () => new Response("", { status }));
16
+ }
17
+ function mockFetchThenRetry() {
18
+ let callCount = 0;
19
+ return (async () => {
20
+ callCount++;
21
+ return new Response("", { status: callCount === 1 ? 401 : 200 });
22
+ });
23
+ }
24
+ function makeConfig(overrides) {
25
+ return {
26
+ isOAuthEnabled: () => true,
27
+ refreshToken: async () => "new-token",
28
+ onTokenRefreshed: () => { },
29
+ buildAuthHeaders: () => ({ Authorization: "Bearer new-token" }),
30
+ ...overrides,
31
+ };
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // headersToPlainObject
35
+ // ---------------------------------------------------------------------------
36
+ describe("headersToPlainObject", () => {
37
+ test("null returns empty object", () => {
38
+ assert.deepStrictEqual(headersToPlainObject(null), {});
39
+ });
40
+ test("undefined returns empty object", () => {
41
+ assert.deepStrictEqual(headersToPlainObject(undefined), {});
42
+ });
43
+ test("plain object passed through", () => {
44
+ const obj = { "Content-Type": "application/json", Accept: "text/html" };
45
+ assert.deepStrictEqual(headersToPlainObject(obj), obj);
46
+ });
47
+ test("Headers instance normalized", () => {
48
+ const h = new Headers();
49
+ h.set("x-custom", "value1");
50
+ h.set("authorization", "Bearer tok");
51
+ const result = headersToPlainObject(h);
52
+ assert.strictEqual(result["x-custom"], "value1");
53
+ assert.strictEqual(result["authorization"], "Bearer tok");
54
+ });
55
+ test("array of tuples normalized", () => {
56
+ const arr = [
57
+ ["x-foo", "bar"],
58
+ ["x-baz", "qux"],
59
+ ];
60
+ assert.deepStrictEqual(headersToPlainObject(arr), {
61
+ "x-foo": "bar",
62
+ "x-baz": "qux",
63
+ });
64
+ });
65
+ });
66
+ // ---------------------------------------------------------------------------
67
+ // isNonReplayableBody
68
+ // ---------------------------------------------------------------------------
69
+ describe("isNonReplayableBody", () => {
70
+ test("null returns false", () => {
71
+ assert.strictEqual(isNonReplayableBody(null), false);
72
+ });
73
+ test("undefined returns false", () => {
74
+ assert.strictEqual(isNonReplayableBody(undefined), false);
75
+ });
76
+ test("empty string returns false", () => {
77
+ assert.strictEqual(isNonReplayableBody(""), false);
78
+ });
79
+ test("plain string returns false", () => {
80
+ assert.strictEqual(isNonReplayableBody("hello"), false);
81
+ });
82
+ test("object with .pipe() returns true (stream-like)", () => {
83
+ assert.strictEqual(isNonReplayableBody({ pipe: () => { } }), true);
84
+ });
85
+ test("object with .read() returns true (stream-like)", () => {
86
+ assert.strictEqual(isNonReplayableBody({ read: () => { } }), true);
87
+ });
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);
93
+ });
94
+ });
95
+ // ---------------------------------------------------------------------------
96
+ // wrapWithAuthRetry
97
+ // ---------------------------------------------------------------------------
98
+ describe("wrapWithAuthRetry", () => {
99
+ test("non-401 response passes through unchanged", async () => {
100
+ const wrapped = wrapWithAuthRetry(mockFetch(200), makeConfig());
101
+ const res = await wrapped("http://example.com");
102
+ assert.strictEqual(res.status, 200);
103
+ });
104
+ test("401 when OAuth disabled passes through unchanged", async () => {
105
+ const config = makeConfig({ isOAuthEnabled: () => false });
106
+ const wrapped = wrapWithAuthRetry(mockFetch(401), config);
107
+ const res = await wrapped("http://example.com");
108
+ assert.strictEqual(res.status, 401);
109
+ });
110
+ test("401 with OAuth enabled triggers refresh and retry", async () => {
111
+ let refreshCalled = false;
112
+ let tokenSet = null;
113
+ const config = makeConfig({
114
+ refreshToken: async () => {
115
+ refreshCalled = true;
116
+ return "refreshed-token";
117
+ },
118
+ onTokenRefreshed: (token) => {
119
+ tokenSet = token;
120
+ },
121
+ buildAuthHeaders: () => ({ Authorization: "Bearer refreshed-token" }),
122
+ });
123
+ const base = mockFetchThenRetry();
124
+ const wrapped = wrapWithAuthRetry(base, config);
125
+ const res = await wrapped("http://example.com");
126
+ assert.strictEqual(res.status, 200);
127
+ assert.strictEqual(refreshCalled, true);
128
+ assert.strictEqual(tokenSet, "refreshed-token");
129
+ });
130
+ test("401 with non-replayable body skips retry", async () => {
131
+ let refreshCalled = false;
132
+ const config = makeConfig({
133
+ refreshToken: async () => {
134
+ refreshCalled = true;
135
+ return "tok";
136
+ },
137
+ });
138
+ const wrapped = wrapWithAuthRetry(mockFetch(401), config);
139
+ const res = await wrapped("http://example.com", {
140
+ body: { pipe: () => { } }, // stream-like
141
+ });
142
+ assert.strictEqual(res.status, 401);
143
+ assert.strictEqual(refreshCalled, false);
144
+ });
145
+ test("concurrent 401s only trigger one refresh (stampede test)", async () => {
146
+ let refreshCount = 0;
147
+ let resolveRefresh = () => { };
148
+ const config = makeConfig({
149
+ refreshToken: () => {
150
+ refreshCount++;
151
+ return new Promise((resolve) => {
152
+ resolveRefresh = resolve;
153
+ });
154
+ },
155
+ buildAuthHeaders: () => ({ Authorization: "Bearer stamped" }),
156
+ });
157
+ // Each call returns 401 first, then 200 on retry
158
+ let callCount = 0;
159
+ const base = (async () => {
160
+ callCount++;
161
+ // First two calls are the initial requests (both 401)
162
+ // Next two are the retries (both 200)
163
+ return new Response("", { status: callCount <= 2 ? 401 : 200 });
164
+ });
165
+ const wrapped = wrapWithAuthRetry(base, config);
166
+ // Fire two concurrent requests
167
+ const p1 = wrapped("http://example.com/a");
168
+ 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
172
+ resolveRefresh("stamped-token");
173
+ const [r1, r2] = await Promise.all([p1, p2]);
174
+ assert.strictEqual(r1.status, 200);
175
+ assert.strictEqual(r2.status, 200);
176
+ assert.strictEqual(refreshCount, 1, "refresh should be called exactly once");
177
+ });
178
+ test("token refresh failure returns original 401 response", async () => {
179
+ const config = makeConfig({
180
+ refreshToken: async () => {
181
+ throw new Error("refresh exploded");
182
+ },
183
+ });
184
+ const wrapped = wrapWithAuthRetry(mockFetch(401), config);
185
+ const res = await wrapped("http://example.com");
186
+ assert.strictEqual(res.status, 401);
187
+ });
188
+ });
@@ -71,8 +71,8 @@ describe("Search Code Tools", () => {
71
71
  if (mockGitLab)
72
72
  await mockGitLab.stop();
73
73
  });
74
- // ---- 1. search toolset exposes exactly 3 tools ----
75
- describe("search toolset exposes exactly 3 tools", () => {
74
+ // ---- 1. search toolset exposes exactly 4 tools ----
75
+ describe("search toolset exposes exactly 4 tools", () => {
76
76
  let server;
77
77
  let tools;
78
78
  before(async () => {
@@ -83,8 +83,8 @@ describe("Search Code Tools", () => {
83
83
  tools = await getToolNames(`http://${HOST}:${port}/mcp`);
84
84
  });
85
85
  after(() => cleanupServers([server]));
86
- test("returns exactly 3 tools", () => {
87
- assert.strictEqual(tools.length, 3, `Expected 3 tools but got ${tools.length}: ${tools.join(", ")}`);
86
+ test("returns exactly 4 tools", () => {
87
+ assert.strictEqual(tools.length, 4, `Expected 4 tools but got ${tools.length}: ${tools.join(", ")}`);
88
88
  });
89
89
  test("includes search_code", () => {
90
90
  assert.ok(tools.includes("search_code"), "Expected search_code to be present");
@@ -95,6 +95,9 @@ describe("Search Code Tools", () => {
95
95
  test("includes search_group_code", () => {
96
96
  assert.ok(tools.includes("search_group_code"), "Expected search_group_code to be present");
97
97
  });
98
+ test("includes discover_tools", () => {
99
+ assert.ok(tools.includes("discover_tools"), "Expected discover_tools to be present");
100
+ });
98
101
  });
99
102
  // ---- 2. search_code returns blob results ----
100
103
  describe("search_code returns blob results", () => {
@@ -392,6 +392,9 @@ describe("Policy Edge Cases", { concurrency: 1 }, () => {
392
392
  "update_issue", "delete_issue", "create_issue_note", "update_issue_note",
393
393
  "list_issue_links", "list_issue_discussions", "get_issue_link",
394
394
  "create_issue_link", "delete_issue_link", "create_note",
395
+ "list_issue_emoji_reactions", "list_issue_note_emoji_reactions",
396
+ "create_issue_emoji_reaction", "delete_issue_emoji_reaction",
397
+ "create_issue_note_emoji_reaction", "delete_issue_note_emoji_reaction",
395
398
  ].join(",");
396
399
  const server = await launchMcp(mockGitLabUrl, {
397
400
  GITLAB_TOOLSETS: "issues",
@@ -16,8 +16,8 @@ const MOCK_PORT_BASE = 9200;
16
16
  const MCP_PORT_BASE = 3200;
17
17
  // Known tool counts per toolset (from TOOLSET_DEFINITIONS)
18
18
  const TOOLSET_TOOL_COUNTS = {
19
- merge_requests: 34,
20
- issues: 14,
19
+ merge_requests: 40,
20
+ issues: 20,
21
21
  repositories: 7,
22
22
  branches: 4,
23
23
  projects: 8,
@@ -28,7 +28,7 @@ const TOOLSET_TOOL_COUNTS = {
28
28
  releases: 7,
29
29
  users: 5,
30
30
  search: 3,
31
- workitems: 12,
31
+ workitems: 18,
32
32
  webhooks: 3,
33
33
  };
34
34
  const DEFAULT_TOOLSETS = [
@@ -286,6 +286,8 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
286
286
  "list_issue_links",
287
287
  "list_issue_discussions",
288
288
  "get_issue_link",
289
+ "list_issue_emoji_reactions",
290
+ "list_issue_note_emoji_reactions",
289
291
  ];
290
292
  const writeIssueTools = [
291
293
  "create_issue",
@@ -296,6 +298,10 @@ describe("Toolset Filtering", { concurrency: 1 }, () => {
296
298
  "create_issue_link",
297
299
  "delete_issue_link",
298
300
  "create_note",
301
+ "create_issue_emoji_reaction",
302
+ "delete_issue_emoji_reaction",
303
+ "create_issue_note_emoji_reaction",
304
+ "delete_issue_note_emoji_reaction",
299
305
  ];
300
306
  before(async () => {
301
307
  const port = await nextMcpPort();
@@ -1,7 +1,7 @@
1
1
  import { zodToJsonSchema } from "zod-to-json-schema";
2
2
  import { toJSONSchema } from "../utils/schema.js";
3
3
  import { USE_GITLAB_WIKI, USE_MILESTONE, USE_PIPELINE, } from "../config.js";
4
- import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateLabelSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestNoteSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteWikiPageSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTimelineEventsSchema, GetUsersSchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListCommitsSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, } from "../schemas.js";
4
+ import { ApproveMergeRequestSchema, BulkPublishDraftNotesSchema, CancelPipelineJobSchema, CancelPipelineSchema, ConvertWorkItemTypeSchema, CreateBranchSchema, CreateDraftNoteSchema, CreateGroupWikiPageSchema, CreateIssueLinkSchema, CreateIssueNoteSchema, CreateIssueSchema, CreateIssueEmojiReactionSchema, CreateIssueNoteEmojiReactionSchema, ListIssueEmojiReactionsSchema, ListIssueNoteEmojiReactionsSchema, CreateLabelSchema, CreateMergeRequestDiscussionNoteSchema, CreateMergeRequestEmojiReactionSchema, ListMergeRequestEmojiReactionsSchema, ListMergeRequestNoteEmojiReactionsSchema, CreateMergeRequestNoteSchema, CreateMergeRequestNoteEmojiReactionSchema, CreateMergeRequestSchema, CreateMergeRequestThreadSchema, CreateNoteSchema, CreateOrUpdateFileSchema, CreatePipelineSchema, CreateProjectMilestoneSchema, CreateReleaseEvidenceSchema, CreateReleaseSchema, CreateRepositorySchema, CreateTimelineEventSchema, CreateWikiPageSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, CreateWorkItemSchema, DeleteDraftNoteSchema, DeleteGroupWikiPageSchema, DeleteIssueLinkSchema, DeleteIssueSchema, DeleteIssueEmojiReactionSchema, DeleteIssueNoteEmojiReactionSchema, DeleteLabelSchema, DeleteMergeRequestDiscussionNoteSchema, DeleteMergeRequestNoteSchema, DeleteMergeRequestEmojiReactionSchema, DeleteMergeRequestNoteEmojiReactionSchema, DeleteProjectMilestoneSchema, DeleteReleaseSchema, DeleteWikiPageSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, DownloadReleaseAssetSchema, EditProjectMilestoneSchema, ExecuteGraphQLSchema, ForkRepositorySchema, GetBranchDiffsSchema, GetCommitDiffSchema, GetCommitSchema, GetDeploymentSchema, GetDraftNoteSchema, GetEnvironmentSchema, GetFileContentsSchema, GetGroupWikiPageSchema, GetIssueLinkSchema, GetIssueSchema, GetJobArtifactFileSchema, GetLabelSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GetMergeRequestDiffsSchema, GetMergeRequestFileDiffSchema, GetMergeRequestNoteSchema, GetMergeRequestNotesSchema, GetMergeRequestSchema, GetMergeRequestVersionSchema, GetMilestoneBurndownEventsSchema, GetMilestoneIssuesSchema, GetMilestoneMergeRequestsSchema, GetNamespaceSchema, GetPipelineJobOutputSchema, GetPipelineSchema, GetProjectEventsSchema, GetProjectMilestoneSchema, GetProjectSchema, GetReleaseSchema, GetRepositoryTreeSchema, GetTimelineEventsSchema, GetUsersSchema, GetWebhookEventSchema, GetWikiPageSchema, GetWorkItemSchema, ListCommitsSchema, ListCustomFieldDefinitionsSchema, ListDeploymentsSchema, ListDraftNotesSchema, ListEnvironmentsSchema, ListEventsSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, ListGroupWikiPagesSchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListJobArtifactsSchema, ListLabelsSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiffsSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestVersionsSchema, ListMergeRequestsSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelineTriggerJobsSchema, ListPipelinesSchema, ListProjectMembersSchema, ListProjectMilestonesSchema, ListProjectsSchema, ListReleasesSchema, ListWebhookEventsSchema, ListWebhooksSchema, ListWikiPagesSchema, ListWorkItemNotesSchema, ListWorkItemStatusesSchema, ListWorkItemsSchema, MarkdownUploadSchema, MergeMergeRequestSchema, MoveWorkItemSchema, MyIssuesSchema, PlayPipelineJobSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PushFilesSchema, ResolveMergeRequestThreadSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateLabelSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestSchema, UpdateReleaseSchema, UpdateWikiPageSchema, UpdateWorkItemSchema, VerifyNamespaceSchema, } from "../schemas.js";
5
5
  // Define all available tools
6
6
  export const allTools = [
7
7
  {
@@ -219,6 +219,37 @@ export const allTools = [
219
219
  description: "Publish all draft notes for a merge request",
220
220
  inputSchema: toJSONSchema(BulkPublishDraftNotesSchema),
221
221
  },
222
+ // --- Merge request emoji reaction tools ---
223
+ {
224
+ name: "list_merge_request_emoji_reactions",
225
+ description: "List all emoji reactions on a merge request",
226
+ inputSchema: toJSONSchema(ListMergeRequestEmojiReactionsSchema),
227
+ },
228
+ {
229
+ name: "list_merge_request_note_emoji_reactions",
230
+ description: "List all emoji reactions on a merge request note. Pass discussion_id for discussion thread replies.",
231
+ inputSchema: toJSONSchema(ListMergeRequestNoteEmojiReactionsSchema),
232
+ },
233
+ {
234
+ name: "create_merge_request_emoji_reaction",
235
+ description: "Add an emoji reaction to a merge request (e.g. thumbsup, rocket, eyes)",
236
+ inputSchema: toJSONSchema(CreateMergeRequestEmojiReactionSchema),
237
+ },
238
+ {
239
+ name: "delete_merge_request_emoji_reaction",
240
+ description: "Remove an emoji reaction from a merge request",
241
+ inputSchema: toJSONSchema(DeleteMergeRequestEmojiReactionSchema),
242
+ },
243
+ {
244
+ name: "create_merge_request_note_emoji_reaction",
245
+ description: "Add an emoji reaction to a merge request note. Pass discussion_id for discussion thread replies.",
246
+ inputSchema: toJSONSchema(CreateMergeRequestNoteEmojiReactionSchema),
247
+ },
248
+ {
249
+ name: "delete_merge_request_note_emoji_reaction",
250
+ description: "Remove an emoji reaction from a merge request note. Pass discussion_id for discussion thread replies.",
251
+ inputSchema: toJSONSchema(DeleteMergeRequestNoteEmojiReactionSchema),
252
+ },
222
253
  {
223
254
  name: "update_issue_note",
224
255
  description: "Modify an existing issue thread note",
@@ -229,6 +260,37 @@ export const allTools = [
229
260
  description: "Add a note to an issue, optionally replying to a discussion thread",
230
261
  inputSchema: toJSONSchema(CreateIssueNoteSchema),
231
262
  },
263
+ // --- Issue emoji reaction tools ---
264
+ {
265
+ name: "list_issue_emoji_reactions",
266
+ description: "List all emoji reactions on an issue",
267
+ inputSchema: toJSONSchema(ListIssueEmojiReactionsSchema),
268
+ },
269
+ {
270
+ name: "list_issue_note_emoji_reactions",
271
+ description: "List all emoji reactions on an issue note. Pass discussion_id for discussion thread replies.",
272
+ inputSchema: toJSONSchema(ListIssueNoteEmojiReactionsSchema),
273
+ },
274
+ {
275
+ name: "create_issue_emoji_reaction",
276
+ description: "Add an emoji reaction to an issue (e.g. thumbsup, rocket, eyes)",
277
+ inputSchema: toJSONSchema(CreateIssueEmojiReactionSchema),
278
+ },
279
+ {
280
+ name: "delete_issue_emoji_reaction",
281
+ description: "Remove an emoji reaction from an issue",
282
+ inputSchema: toJSONSchema(DeleteIssueEmojiReactionSchema),
283
+ },
284
+ {
285
+ name: "create_issue_note_emoji_reaction",
286
+ description: "Add an emoji reaction to an issue note. Pass discussion_id for discussion thread replies.",
287
+ inputSchema: toJSONSchema(CreateIssueNoteEmojiReactionSchema),
288
+ },
289
+ {
290
+ name: "delete_issue_note_emoji_reaction",
291
+ description: "Remove an emoji reaction from an issue note. Pass discussion_id for discussion thread replies.",
292
+ inputSchema: toJSONSchema(DeleteIssueNoteEmojiReactionSchema),
293
+ },
232
294
  {
233
295
  name: "list_issues",
234
296
  description: "List issues (default: created by current user; use scope='all' for all)",
@@ -670,6 +732,37 @@ export const allTools = [
670
732
  description: "Add a note to a work item (supports Markdown, internal notes, threads)",
671
733
  inputSchema: toJSONSchema(CreateWorkItemNoteSchema),
672
734
  },
735
+ // --- Work item emoji reaction tools (GraphQL-based) ---
736
+ {
737
+ name: "list_work_item_emoji_reactions",
738
+ description: "List all emoji reactions on a work item",
739
+ inputSchema: toJSONSchema(ListWorkItemEmojiReactionsSchema),
740
+ },
741
+ {
742
+ name: "list_work_item_note_emoji_reactions",
743
+ description: "List all emoji reactions on a work item note (comment, thread, or thread reply)",
744
+ inputSchema: toJSONSchema(ListWorkItemNoteEmojiReactionsSchema),
745
+ },
746
+ {
747
+ name: "create_work_item_emoji_reaction",
748
+ description: "Add an emoji reaction to a work item (e.g. thumbsup, rocket, eyes)",
749
+ inputSchema: toJSONSchema(CreateWorkItemEmojiReactionSchema),
750
+ },
751
+ {
752
+ name: "delete_work_item_emoji_reaction",
753
+ description: "Remove an emoji reaction from a work item",
754
+ inputSchema: toJSONSchema(DeleteWorkItemEmojiReactionSchema),
755
+ },
756
+ {
757
+ name: "create_work_item_note_emoji_reaction",
758
+ description: "Add an emoji reaction to a work item note (comment, thread, or thread reply)",
759
+ inputSchema: toJSONSchema(CreateWorkItemNoteEmojiReactionSchema),
760
+ },
761
+ {
762
+ name: "delete_work_item_note_emoji_reaction",
763
+ description: "Remove an emoji reaction from a work item note (comment, thread, or thread reply)",
764
+ inputSchema: toJSONSchema(DeleteWorkItemNoteEmojiReactionSchema),
765
+ },
673
766
  // --- Incident timeline event tools ---
674
767
  {
675
768
  name: "get_timeline_events",
@@ -805,6 +898,12 @@ export const readOnlyTools = new Set([
805
898
  "list_work_item_statuses",
806
899
  "list_custom_field_definitions",
807
900
  "list_work_item_notes",
901
+ "list_merge_request_emoji_reactions",
902
+ "list_merge_request_note_emoji_reactions",
903
+ "list_issue_emoji_reactions",
904
+ "list_issue_note_emoji_reactions",
905
+ "list_work_item_emoji_reactions",
906
+ "list_work_item_note_emoji_reactions",
808
907
  "get_timeline_events",
809
908
  "get_merge_request_conflicts",
810
909
  "list_webhooks",
@@ -823,6 +922,12 @@ export const destructiveTools = new Set([
823
922
  "delete_merge_request_note",
824
923
  "delete_merge_request_discussion_note",
825
924
  "delete_draft_note",
925
+ "delete_merge_request_emoji_reaction",
926
+ "delete_merge_request_note_emoji_reaction",
927
+ "delete_issue_emoji_reaction",
928
+ "delete_issue_note_emoji_reaction",
929
+ "delete_work_item_emoji_reaction",
930
+ "delete_work_item_note_emoji_reaction",
826
931
  "merge_merge_request",
827
932
  "push_files",
828
933
  ]);
@@ -913,6 +1018,12 @@ export const TOOLSET_DEFINITIONS = [
913
1018
  "bulk_publish_draft_notes",
914
1019
  "create_merge_request_thread",
915
1020
  "resolve_merge_request_thread",
1021
+ "list_merge_request_emoji_reactions",
1022
+ "list_merge_request_note_emoji_reactions",
1023
+ "create_merge_request_emoji_reaction",
1024
+ "delete_merge_request_emoji_reaction",
1025
+ "create_merge_request_note_emoji_reaction",
1026
+ "delete_merge_request_note_emoji_reaction",
916
1027
  ]),
917
1028
  },
918
1029
  {
@@ -933,6 +1044,12 @@ export const TOOLSET_DEFINITIONS = [
933
1044
  "create_issue_link",
934
1045
  "delete_issue_link",
935
1046
  "create_note",
1047
+ "list_issue_emoji_reactions",
1048
+ "list_issue_note_emoji_reactions",
1049
+ "create_issue_emoji_reaction",
1050
+ "delete_issue_emoji_reaction",
1051
+ "create_issue_note_emoji_reaction",
1052
+ "delete_issue_note_emoji_reaction",
936
1053
  ]),
937
1054
  },
938
1055
  {
@@ -1077,6 +1194,12 @@ export const TOOLSET_DEFINITIONS = [
1077
1194
  "move_work_item",
1078
1195
  "list_work_item_notes",
1079
1196
  "create_work_item_note",
1197
+ "list_work_item_emoji_reactions",
1198
+ "list_work_item_note_emoji_reactions",
1199
+ "create_work_item_emoji_reaction",
1200
+ "delete_work_item_emoji_reaction",
1201
+ "create_work_item_note_emoji_reaction",
1202
+ "delete_work_item_note_emoji_reaction",
1080
1203
  "get_timeline_events",
1081
1204
  "create_timeline_event",
1082
1205
  ]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
5
5
  "keywords": [
6
6
  "gitlab",
@@ -48,10 +48,10 @@
48
48
  "changelog": "auto-changelog -p",
49
49
  "test": "npm run test:all",
50
50
  "test:all": "npm run build && npm run test:mock && npm run test:live",
51
- "test:mock": "npx tsx --test test/remote-auth-simple-test.ts && npx tsx --test test/mcp-oauth-tests.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && tsx --test test/test-job-artifacts.ts && tsx --test test/test-deployment-tools.ts && tsx --test test/test-merge-request-approval-state-tools.ts && npx tsx --test test/test-search-code.ts && npx tsx --test test/test-toolset-filtering.ts",
52
- "test:mcp-oauth": "npm run build && npx tsx --test test/mcp-oauth-tests.ts",
51
+ "test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-auth-retry.ts",
52
+ "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
53
53
  "test:live": "node test/validate-api.js",
54
- "test:remote-auth": "npm run build && npx tsx --test test/remote-auth-simple-test.ts",
54
+ "test:remote-auth": "npm run build && node --import tsx/esm --test test/remote-auth-simple-test.ts",
55
55
  "test:schema": "tsx test/schema-tests.ts",
56
56
  "test:oauth": "tsx test/oauth-tests.ts",
57
57
  "test:list-merge-requests": "npm run build && tsx test/test-list-merge-requests.ts",