@zereight/mcp-gitlab 2.1.0 → 2.1.2

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
@@ -527,7 +527,7 @@ export const GitLabRepositorySchema = z.object({
527
527
  http_url_to_repo: z.string().optional(),
528
528
  created_at: z.string().optional(),
529
529
  last_activity_at: z.string().optional(),
530
- default_branch: z.string().optional(),
530
+ default_branch: z.string().nullable().optional(),
531
531
  namespace: z
532
532
  .object({
533
533
  id: z.coerce.string(),
@@ -621,7 +621,7 @@ export const FileOperationSchema = z.object({
621
621
  export const GitLabTreeItemSchema = z.object({
622
622
  id: z.string(),
623
623
  name: z.string(),
624
- type: z.enum(["tree", "blob"]),
624
+ type: z.enum(["tree", "blob", "commit"]),
625
625
  path: z.string(),
626
626
  mode: z.string(),
627
627
  });
@@ -1219,7 +1219,7 @@ export const CreateIssueSchema = ProjectParamsSchema.extend({
1219
1219
  title: z.string().describe("Issue title"),
1220
1220
  description: z.string().optional().describe("Issue description"),
1221
1221
  assignee_ids: z.array(z.coerce.number()).optional().describe("Array of user IDs to assign"),
1222
- labels: z.array(z.string()).optional().describe("Array of label names"),
1222
+ labels: coerceStringArray.optional().describe("Array of label names"),
1223
1223
  milestone_id: z.coerce.string().optional().describe("Milestone ID to assign"),
1224
1224
  issue_type: z
1225
1225
  .enum(["issue", "incident", "test_case", "task"])
@@ -1239,7 +1239,7 @@ const MergeRequestOptionsSchema = {
1239
1239
  .array(z.coerce.number())
1240
1240
  .optional()
1241
1241
  .describe("The ID of the users to assign as reviewers of the MR"),
1242
- labels: z.array(z.string()).optional().describe("Labels for the MR"),
1242
+ labels: coerceStringArray.optional().describe("Labels for the MR"),
1243
1243
  draft: z.coerce.boolean().optional().describe("Create as draft merge request"),
1244
1244
  allow_collaboration: z.coerce.boolean().optional().describe("Allow commits from upstream members"),
1245
1245
  remove_source_branch: z
@@ -1288,7 +1288,7 @@ export const UpdateMergeRequestSchema = GetMergeRequestSchema.extend({
1288
1288
  .array(z.coerce.number())
1289
1289
  .optional()
1290
1290
  .describe("The ID of the users to assign as reviewers of the MR"),
1291
- labels: z.array(z.string()).optional().describe("Labels for the MR"),
1291
+ labels: coerceStringArray.optional().describe("Labels for the MR"),
1292
1292
  state_event: z
1293
1293
  .enum(["close", "reopen"])
1294
1294
  .optional()
@@ -1475,7 +1475,7 @@ export const ListIssuesSchema = z
1475
1475
  created_after: z.string().optional().describe("Return issues created after the given time"),
1476
1476
  created_before: z.string().optional().describe("Return issues created before the given time"),
1477
1477
  due_date: z.string().optional().describe("Return issues that have the due date"),
1478
- labels: z.array(z.string()).optional().describe("Array of label names"),
1478
+ labels: coerceStringArray.optional().describe("Array of label names"),
1479
1479
  milestone: z.string().optional().describe("Milestone title"),
1480
1480
  issue_type: z
1481
1481
  .enum(["issue", "incident", "test_case", "task"])
@@ -1546,7 +1546,7 @@ export const ListMergeRequestsSchema = z
1546
1546
  .string()
1547
1547
  .optional()
1548
1548
  .describe("Return merge requests updated before the given time"),
1549
- labels: z.array(z.string()).optional().describe("Array of label names"),
1549
+ labels: coerceStringArray.optional().describe("Array of label names"),
1550
1550
  milestone: z.string().optional().describe("Milestone title"),
1551
1551
  scope: z
1552
1552
  .enum(["created_by_me", "assigned_to_me", "all"])
@@ -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
@@ -2134,7 +2136,7 @@ export const MyIssuesSchema = z.object({
2134
2136
  .enum(["opened", "closed", "all"])
2135
2137
  .optional()
2136
2138
  .describe("Return issues with a specific state (default: opened)"),
2137
- labels: z.array(z.string()).optional().describe("Array of label names to filter by"),
2139
+ labels: coerceStringArray.optional().describe("Array of label names to filter by"),
2138
2140
  milestone: z.string().optional().describe("Milestone title to filter by"),
2139
2141
  search: z.string().optional().describe("Search for specific terms in title and description"),
2140
2142
  created_after: z
@@ -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"),