@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.
- package/build/auth-retry.js +81 -0
- package/build/index.js +239 -10
- package/build/oauth.js +4 -4
- package/build/schemas.js +96 -2
- package/build/test/mcp-oauth-tests.js +49 -0
- package/build/test/schema-tests.js +59 -3
- package/build/test/test-auth-retry.js +188 -0
- package/build/test/test-search-code.js +7 -4
- package/build/test/test-token-optimizations.js +3 -0
- package/build/test/test-toolset-filtering.js +9 -3
- package/build/tools/registry.js +124 -1
- package/package.json +4 -4
|
@@ -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
|
-
|
|
7175
|
-
//
|
|
7176
|
-
//
|
|
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
|
|
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
|
|
380
|
-
const
|
|
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
|
|
75
|
-
describe("search toolset exposes exactly
|
|
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
|
|
87
|
-
assert.strictEqual(tools.length,
|
|
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:
|
|
20
|
-
issues:
|
|
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:
|
|
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();
|
package/build/tools/registry.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
52
|
-
"test:mcp-oauth": "npm run build &&
|
|
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 &&
|
|
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",
|