@zereight/mcp-gitlab 2.0.36 → 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/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
@@ -1,4 +1,17 @@
1
1
  import { z } from "zod";
2
+ // Helper: coerce a JSON-stringified array to an actual array.
3
+ // LLMs sometimes send '["a", "b"]' (string) instead of ["a", "b"] (array).
4
+ const coerceStringArray = z.preprocess((val) => {
5
+ if (typeof val === "string") {
6
+ try {
7
+ const parsed = JSON.parse(val);
8
+ if (Array.isArray(parsed))
9
+ return parsed;
10
+ }
11
+ catch { /* not JSON, fall through */ }
12
+ }
13
+ return val;
14
+ }, z.array(z.string()));
2
15
  // Base schemas for common types
3
16
  export const GitLabAuthorSchema = z.object({
4
17
  name: z.string(),
@@ -904,7 +917,7 @@ export const GitLabMergeRequestSchema = z.object({
904
917
  .optional()
905
918
  .describe("Number of commits the source branch is behind the target branch"),
906
919
  rebase_in_progress: z
907
- .boolean()
920
+ .coerce.boolean()
908
921
  .optional()
909
922
  .describe("Whether rebase is currently in progress for this merge request"),
910
923
  merge_when_pipeline_succeeds: z.coerce.boolean().optional(),
@@ -1135,9 +1148,17 @@ export const CreateOrUpdateFileSchema = ProjectParamsSchema.extend({
1135
1148
  });
1136
1149
  export const SearchRepositoriesSchema = z
1137
1150
  .object({
1138
- search: z.string().describe("Search query"), // Changed from query to match GitLab API
1151
+ search: z.string().optional().describe("Search query"),
1152
+ query: z.string().optional().describe("Search query (alias for 'search')"),
1139
1153
  })
1140
- .merge(PaginationOptionsSchema);
1154
+ .merge(PaginationOptionsSchema)
1155
+ .transform((data) => {
1156
+ const search = data.search || data.query;
1157
+ if (!search) {
1158
+ throw new Error("Either 'search' or 'query' must be provided");
1159
+ }
1160
+ return { ...data, search, query: undefined };
1161
+ });
1141
1162
  export const CreateRepositorySchema = z.object({
1142
1163
  name: z.string().describe("Repository name"),
1143
1164
  description: z.string().optional().describe("Repository description"),
@@ -1222,12 +1243,12 @@ const MergeRequestOptionsSchema = {
1222
1243
  draft: z.coerce.boolean().optional().describe("Create as draft merge request"),
1223
1244
  allow_collaboration: z.coerce.boolean().optional().describe("Allow commits from upstream members"),
1224
1245
  remove_source_branch: z
1225
- .boolean()
1246
+ .coerce.boolean()
1226
1247
  .nullable()
1227
1248
  .optional()
1228
1249
  .describe("Flag indicating if a merge request should remove the source branch when merging."),
1229
1250
  squash: z
1230
- .boolean()
1251
+ .coerce.boolean()
1231
1252
  .nullable()
1232
1253
  .optional()
1233
1254
  .describe("If true, squash all commits into a single commit on merge."),
@@ -1246,7 +1267,7 @@ export const GetBranchDiffsSchema = ProjectParamsSchema.extend({
1246
1267
  from: z.string().describe("The base branch or commit SHA to compare from"),
1247
1268
  to: z.string().describe("The target branch or commit SHA to compare to"),
1248
1269
  straight: z
1249
- .boolean()
1270
+ .coerce.boolean()
1250
1271
  .optional()
1251
1272
  .describe("Comparison method: false for '...' (default), true for '--'"),
1252
1273
  excluded_file_patterns: z
@@ -1273,7 +1294,7 @@ export const UpdateMergeRequestSchema = GetMergeRequestSchema.extend({
1273
1294
  .optional()
1274
1295
  .describe("New state (close/reopen) for the MR"),
1275
1296
  remove_source_branch: z
1276
- .boolean()
1297
+ .coerce.boolean()
1277
1298
  .optional()
1278
1299
  .describe("Flag indicating if the source branch should be removed"),
1279
1300
  squash: z.coerce.boolean().optional().describe("Squash commits into a single commit when merging"),
@@ -1281,24 +1302,24 @@ export const UpdateMergeRequestSchema = GetMergeRequestSchema.extend({
1281
1302
  });
1282
1303
  export const MergeMergeRequestSchema = ProjectParamsSchema.extend({
1283
1304
  merge_request_iid: z.coerce.string().optional().describe("The IID of a merge request"),
1284
- auto_merge: z
1305
+ auto_merge: z.coerce
1285
1306
  .boolean()
1286
1307
  .optional()
1287
1308
  .default(false)
1288
1309
  .describe("If true, the merge request merges when the pipeline succeeds."),
1289
1310
  merge_commit_message: z.string().optional().describe("Custom merge commit message"),
1290
- merge_when_pipeline_succeeds: z
1311
+ merge_when_pipeline_succeeds: z.coerce
1291
1312
  .boolean()
1292
1313
  .optional()
1293
1314
  .default(false)
1294
1315
  .describe("If true, the merge request merges when the pipeline succeeds.in GitLab 17.11. Use"),
1295
- should_remove_source_branch: z
1316
+ should_remove_source_branch: z.coerce
1296
1317
  .boolean()
1297
1318
  .optional()
1298
1319
  .default(false)
1299
1320
  .describe("Remove source branch after merge"),
1300
1321
  squash_commit_message: z.string().optional().describe("Custom squash commit message"),
1301
- squash: z
1322
+ squash: z.coerce
1302
1323
  .boolean()
1303
1324
  .optional()
1304
1325
  .default(false)
@@ -1394,7 +1415,7 @@ export const ListMergeRequestDiffsSchema = GetMergeRequestSchema.extend({
1394
1415
  page: z.coerce.number().optional().describe("Page number for pagination (default: 1)"),
1395
1416
  per_page: z.coerce.number().optional().describe("Number of items per page (max: 100, default: 20)"),
1396
1417
  unidiff: z
1397
- .boolean()
1418
+ .coerce.boolean()
1398
1419
  .optional()
1399
1420
  .describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
1400
1421
  });
@@ -1410,7 +1431,7 @@ export const GetMergeRequestFileDiffSchema = GetMergeRequestSchema.extend({
1410
1431
  .describe("List of file paths to retrieve diffs for (e.g. ['src/api/users.ts', 'src/repo/user.go']). " +
1411
1432
  "Call list_merge_request_changed_files first to get the full list of changed paths."),
1412
1433
  unidiff: z
1413
- .boolean()
1434
+ .coerce.boolean()
1414
1435
  .optional()
1415
1436
  .describe("Present diff in the unified diff format. Default is false."),
1416
1437
  });
@@ -1421,7 +1442,7 @@ export const ListMergeRequestVersionsSchema = ProjectParamsSchema.extend({
1421
1442
  export const GetMergeRequestVersionSchema = ListMergeRequestVersionsSchema.extend({
1422
1443
  version_id: z.coerce.string().describe("The ID of the merge request diff version"),
1423
1444
  unidiff: z
1424
- .boolean()
1445
+ .coerce.boolean()
1425
1446
  .optional()
1426
1447
  .describe("Present diffs in the unified diff format. Default is false. Introduced in GitLab 16.5."),
1427
1448
  });
@@ -1579,14 +1600,11 @@ export const UpdateIssueSchema = z.object({
1579
1600
  confidential: z.coerce.boolean().optional().describe("Set the issue to be confidential"),
1580
1601
  discussion_locked: z.coerce.boolean().optional().describe("Flag to lock discussions"),
1581
1602
  due_date: z.string().optional().describe("Date the issue is due (YYYY-MM-DD)"),
1582
- labels: z.array(z.string()).optional().describe("Array of label names"),
1603
+ labels: coerceStringArray.optional().describe("Array of label names"),
1583
1604
  milestone_id: z.coerce.string().optional().describe("Milestone ID to assign"),
1584
1605
  state_event: z.enum(["close", "reopen"]).optional().describe("Update issue state (close/reopen)"),
1585
1606
  weight: z.coerce.number().optional().describe("Weight of the issue (numeric, typically hours of work)"),
1586
- issue_type: z
1587
- .enum(["issue", "incident", "test_case", "task"])
1588
- .optional()
1589
- .describe("The type of issue. One of issue, incident, test_case or task."),
1607
+ issue_type: z.preprocess((val) => (typeof val === "string" ? val.toLowerCase() : val), z.enum(["issue", "incident", "test_case", "task"]).optional()).describe("The type of issue. One of issue, incident, test_case or task."),
1590
1608
  });
1591
1609
  export const DeleteIssueSchema = z.object({
1592
1610
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -1645,7 +1663,7 @@ export const ListProjectsSchema = z
1645
1663
  search_namespaces: z.coerce.boolean().optional().describe("Needs to be true if search is full path"),
1646
1664
  owned: z.coerce.boolean().optional().describe("Filter for projects owned by current user"),
1647
1665
  membership: z
1648
- .boolean()
1666
+ .coerce.boolean()
1649
1667
  .optional()
1650
1668
  .describe("Filter for projects where current user is a member"),
1651
1669
  simple: z.coerce.boolean().optional().describe("Return only limited fields"),
@@ -1663,21 +1681,22 @@ export const ListProjectsSchema = z
1663
1681
  .optional()
1664
1682
  .describe("Return projects sorted in ascending or descending order"),
1665
1683
  with_issues_enabled: z
1666
- .boolean()
1684
+ .coerce.boolean()
1667
1685
  .optional()
1668
1686
  .describe("Filter projects with issues feature enabled"),
1669
1687
  with_merge_requests_enabled: z
1670
- .boolean()
1688
+ .coerce.boolean()
1671
1689
  .optional()
1672
1690
  .describe("Filter projects with merge requests feature enabled"),
1673
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)"),
1674
1693
  })
1675
1694
  .merge(PaginationOptionsSchema);
1676
1695
  // Label operation schemas
1677
1696
  export const ListLabelsSchema = z.object({
1678
1697
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1679
1698
  with_counts: z
1680
- .boolean()
1699
+ .coerce.boolean()
1681
1700
  .optional()
1682
1701
  .describe("Whether or not to include issue and merge request counts"),
1683
1702
  include_ancestor_groups: z.coerce.boolean().optional().describe("Include ancestor groups"),
@@ -1729,11 +1748,11 @@ export const ListGroupProjectsSchema = z
1729
1748
  .optional()
1730
1749
  .describe("Filter by project visibility"),
1731
1750
  with_issues_enabled: z
1732
- .boolean()
1751
+ .coerce.boolean()
1733
1752
  .optional()
1734
1753
  .describe("Filter projects with issues feature enabled"),
1735
1754
  with_merge_requests_enabled: z
1736
- .boolean()
1755
+ .coerce.boolean()
1737
1756
  .optional()
1738
1757
  .describe("Filter projects with merge requests feature enabled"),
1739
1758
  min_access_level: z.coerce.number().optional().describe("Filter by minimum access level"),
@@ -1742,6 +1761,7 @@ export const ListGroupProjectsSchema = z
1742
1761
  statistics: z.coerce.boolean().optional().describe("Include project statistics"),
1743
1762
  with_custom_attributes: z.coerce.boolean().optional().describe("Include custom attributes"),
1744
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)"),
1745
1765
  })
1746
1766
  .merge(PaginationOptionsSchema);
1747
1767
  // Add wiki operation schemas
@@ -1785,7 +1805,7 @@ export const GitLabWikiPageSchema = z.object({
1785
1805
  export const ListGroupWikiPagesSchema = z
1786
1806
  .object({
1787
1807
  group_id: z.coerce.string().describe("Group ID or URL-encoded path"),
1788
- with_content: z.boolean().optional().describe("Include content of the wiki pages"),
1808
+ with_content: z.coerce.boolean().optional().describe("Include content of the wiki pages"),
1789
1809
  })
1790
1810
  .merge(PaginationOptionsSchema);
1791
1811
  export const GetGroupWikiPageSchema = z.object({
@@ -1966,7 +1986,7 @@ export const CreateDraftNoteSchema = ProjectParamsSchema.extend({
1966
1986
  .describe("The ID of a discussion the draft note replies to"),
1967
1987
  position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1968
1988
  resolve_discussion: z
1969
- .boolean()
1989
+ .coerce.boolean()
1970
1990
  .optional()
1971
1991
  .describe("Whether to resolve the discussion when publishing"),
1972
1992
  });
@@ -1977,7 +1997,7 @@ export const UpdateDraftNoteSchema = ProjectParamsSchema.extend({
1977
1997
  body: z.string().optional().describe("The content of the draft note"),
1978
1998
  position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1979
1999
  resolve_discussion: z
1980
- .boolean()
2000
+ .coerce.boolean()
1981
2001
  .optional()
1982
2002
  .describe("Whether to resolve the discussion when publishing"),
1983
2003
  });
@@ -2085,7 +2105,7 @@ export const ListCommitsSchema = z.object({
2085
2105
  all: z.coerce.boolean().optional().describe("Retrieve every commit from the repository"),
2086
2106
  with_stats: z.coerce.boolean().optional().describe("Stats about each commit are added to the response"),
2087
2107
  first_parent: z
2088
- .boolean()
2108
+ .coerce.boolean()
2089
2109
  .optional()
2090
2110
  .describe("Follow only the first parent commit upon seeing a merge commit"),
2091
2111
  order: z.enum(["default", "topo"]).optional().describe("List commits in order"),
@@ -2102,7 +2122,7 @@ export const GetCommitDiffSchema = z.object({
2102
2122
  project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
2103
2123
  sha: z.string().describe("The commit hash or name of a repository branch or tag"),
2104
2124
  full_diff: z
2105
- .boolean()
2125
+ .coerce.boolean()
2106
2126
  .optional()
2107
2127
  .describe("Whether to return the full diff or only first page (default: false)"),
2108
2128
  });
@@ -2145,7 +2165,7 @@ export const ListProjectMembersSchema = z.object({
2145
2165
  user_ids: z.array(z.coerce.number()).optional().describe("Filter by user IDs"),
2146
2166
  skip_users: z.array(z.coerce.number()).optional().describe("User IDs to exclude"),
2147
2167
  include_inheritance: z
2148
- .boolean()
2168
+ .coerce.boolean()
2149
2169
  .optional()
2150
2170
  .describe("Include inherited members. Defaults to false."),
2151
2171
  per_page: z.coerce.number().optional().describe("Number of items per page (default: 20, max: 100)"),
@@ -2216,11 +2236,11 @@ export const ListGroupIterationsSchema = z
2216
2236
  .optional()
2217
2237
  .describe("Fields in which fuzzy search should be performed with the query given in the argument search. The available options are title and cadence_title. Default is [title]."),
2218
2238
  include_ancestors: z
2219
- .boolean()
2239
+ .coerce.boolean()
2220
2240
  .optional()
2221
2241
  .describe("Include iterations for group and its ancestors. Defaults to true."),
2222
2242
  include_descendants: z
2223
- .boolean()
2243
+ .coerce.boolean()
2224
2244
  .optional()
2225
2245
  .describe("Include iterations for group and its descendants. Defaults to false."),
2226
2246
  updated_before: z
@@ -2255,8 +2275,8 @@ export const GitLabEventSchema = z
2255
2275
  created_at: z.string(),
2256
2276
  author: GitLabEventAuthorSchema,
2257
2277
  author_username: z.string(),
2258
- imported: z.coerce.boolean(),
2259
- imported_from: z.string(),
2278
+ imported: z.coerce.boolean().optional(),
2279
+ imported_from: z.string().nullable().optional(),
2260
2280
  })
2261
2281
  .passthrough(); // Allow additional fields
2262
2282
  // List events schema
@@ -2419,7 +2439,7 @@ export const ListReleasesSchema = z
2419
2439
  .optional()
2420
2440
  .describe("The direction of the order. Either desc (default) for descending order or asc for ascending order."),
2421
2441
  include_html_description: z
2422
- .boolean()
2442
+ .coerce.boolean()
2423
2443
  .optional()
2424
2444
  .describe("If true, a response includes HTML rendered Markdown of the release description."),
2425
2445
  })
@@ -2428,7 +2448,7 @@ export const GetReleaseSchema = z.object({
2428
2448
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2429
2449
  tag_name: z.string().describe("The Git tag the release is associated with"),
2430
2450
  include_html_description: z
2431
- .boolean()
2451
+ .coerce.boolean()
2432
2452
  .optional()
2433
2453
  .describe("If true, a response includes HTML rendered Markdown of the release description."),
2434
2454
  });
@@ -2511,7 +2531,7 @@ export const ListJobArtifactsSchema = z.object({
2511
2531
  .optional()
2512
2532
  .describe("Directory path within the artifacts archive (defaults to root)"),
2513
2533
  recursive: z
2514
- .boolean()
2534
+ .coerce.boolean()
2515
2535
  .optional()
2516
2536
  .describe("Whether to list artifacts recursively"),
2517
2537
  });
@@ -2593,8 +2613,8 @@ export const CreateWorkItemSchema = z.object({
2593
2613
  .default("issue")
2594
2614
  .describe("Type of work item to create. Defaults to 'issue'."),
2595
2615
  description: z.string().optional().describe("Description of the work item (Markdown supported)"),
2596
- labels: z.array(z.string()).optional().describe("Array of label names to assign"),
2597
- assignee_usernames: z.array(z.string()).optional().describe("Array of usernames to assign"),
2616
+ labels: coerceStringArray.optional().describe("Array of label names to assign"),
2617
+ assignee_usernames: coerceStringArray.optional().describe("Array of usernames to assign"),
2598
2618
  parent_iid: z.coerce.number().optional().describe("IID of the parent work item to set hierarchy"),
2599
2619
  weight: z.coerce.number().optional().describe("Weight of the work item"),
2600
2620
  health_status: z.enum(["onTrack", "needsAttention", "atRisk"]).optional().describe("Set health status"),
@@ -2607,9 +2627,9 @@ export const CreateWorkItemSchema = z.object({
2607
2627
  export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
2608
2628
  title: z.string().optional().describe("New title"),
2609
2629
  description: z.string().optional().describe("New description (Markdown supported)"),
2610
- add_labels: z.array(z.string()).optional().describe("Label names to add"),
2611
- remove_labels: z.array(z.string()).optional().describe("Label names to remove"),
2612
- assignee_usernames: z.array(z.string()).optional().describe("Set assignees by username (replaces existing)"),
2630
+ add_labels: coerceStringArray.optional().describe("Label names to add"),
2631
+ remove_labels: coerceStringArray.optional().describe("Label names to remove"),
2632
+ assignee_usernames: coerceStringArray.optional().describe("Set assignees by username (replaces existing)"),
2613
2633
  state_event: z.enum(["close", "reopen"]).optional().describe("Close or reopen the work item"),
2614
2634
  weight: z.coerce.number().optional().describe("Set weight (issues, tasks, epics only)"),
2615
2635
  status: z.string().optional().describe("Set status by ID. Use list_work_item_statuses to get available status IDs."),
@@ -2617,11 +2637,11 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
2617
2637
  parent_project_id: z.coerce.string().optional().describe("Project ID or path of the parent work item (defaults to same project as the work item)"),
2618
2638
  remove_parent: z.coerce.boolean().optional().describe("Set to true to remove the parent from hierarchy"),
2619
2639
  children_to_add: z.array(z.object({
2620
- project_id: z.coerce.string().describe("Project ID or path of the child work item"),
2640
+ project_id: z.coerce.string().optional().describe("Project ID or path of the child work item. Defaults to the parent work item's project if omitted."),
2621
2641
  iid: z.coerce.number().describe("IID of the child work item"),
2622
2642
  })).optional().describe("Array of children to add to this work item's hierarchy"),
2623
2643
  children_to_remove: z.array(z.object({
2624
- project_id: z.coerce.string().describe("Project ID or path of the child work item"),
2644
+ project_id: z.coerce.string().optional().describe("Project ID or path of the child work item. Defaults to the parent work item's project if omitted."),
2625
2645
  iid: z.coerce.number().describe("IID of the child work item"),
2626
2646
  })).optional().describe("Array of children to remove from this work item's hierarchy"),
2627
2647
  health_status: z.enum(["onTrack", "needsAttention", "atRisk"]).optional().describe("Set health status on issues and epics"),
@@ -2631,12 +2651,12 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
2631
2651
  iteration_id: z.string().optional().describe("Iteration ID (e.g. 'gid://gitlab/Iteration/123' or numeric ID). Use list_group_iterations to find available iterations."),
2632
2652
  confidential: z.coerce.boolean().optional().describe("Set confidentiality"),
2633
2653
  linked_items_to_add: z.array(z.object({
2634
- project_id: z.coerce.string().describe("Project ID or path of the work item to link"),
2654
+ project_id: z.coerce.string().optional().describe("Project ID or path of the work item to link. Defaults to the same project if omitted."),
2635
2655
  iid: z.coerce.number().describe("IID of the work item to link"),
2636
2656
  link_type: z.enum(["RELATED", "BLOCKED_BY", "BLOCKS"]).optional().default("RELATED").describe("Link type: RELATED, BLOCKED_BY, or BLOCKS. Defaults to RELATED."),
2637
2657
  })).optional().describe("Work items to link"),
2638
2658
  linked_items_to_remove: z.array(z.object({
2639
- project_id: z.coerce.string().describe("Project ID or path of the linked work item to remove"),
2659
+ project_id: z.coerce.string().optional().describe("Project ID or path of the linked work item to remove. Defaults to the same project if omitted."),
2640
2660
  iid: z.coerce.number().describe("IID of the linked work item to remove"),
2641
2661
  })).optional().describe("Linked work items to remove"),
2642
2662
  custom_fields: z.array(z.object({
@@ -2693,6 +2713,98 @@ export const ListCustomFieldDefinitionsSchema = z.object({
2693
2713
  .default("issue")
2694
2714
  .describe("The work item type to list custom field definitions for. Defaults to 'issue'."),
2695
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
+ });
2696
2808
  // --- Incident Timeline Event schemas ---
2697
2809
  export const GetTimelineEventsSchema = z.object({
2698
2810
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
@@ -2740,7 +2852,7 @@ export const ListWebhookEventsSchema = z
2740
2852
  .optional()
2741
2853
  .describe("Filter by response status code (e.g. 200, 500) or category: successful, client_failure, server_failure"),
2742
2854
  summary: z
2743
- .boolean()
2855
+ .coerce.boolean()
2744
2856
  .optional()
2745
2857
  .describe("If true, return only summary fields (id, url, trigger, response_status, execution_duration) without full request/response payloads. Recommended for overview queries to avoid huge responses."),
2746
2858
  per_page: z
@@ -139,6 +139,55 @@ describe("MCP OAuth — Discovery Endpoints", () => {
139
139
  assert.ok(body.resource, "Should have resource field");
140
140
  console.log(" ✓ Protected resource metadata returned");
141
141
  });
142
+ test("path-prefixed MCP_SERVER_URL serves path-aware discovery metadata", async () => {
143
+ const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 25);
144
+ const prefixedMockGitLab = new MockGitLabServer({
145
+ port: mockPort,
146
+ validTokens: [MOCK_OAUTH_TOKEN],
147
+ });
148
+ await prefixedMockGitLab.start();
149
+ const scopedServers = [];
150
+ try {
151
+ const mockGitLabUrl = prefixedMockGitLab.getUrl();
152
+ addOAuthEndpoints(prefixedMockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
153
+ const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 25);
154
+ const mcpBaseUrl = `http://${HOST}:${mcpPort}`;
155
+ const issuerPath = "/gitlab-mcp";
156
+ const prefixedServerUrl = `${mcpBaseUrl}${issuerPath}`;
157
+ const server = await launchServer({
158
+ mode: TransportMode.STREAMABLE_HTTP,
159
+ port: mcpPort,
160
+ timeout: 5000,
161
+ env: {
162
+ STREAMABLE_HTTP: "true",
163
+ GITLAB_MCP_OAUTH: "true",
164
+ GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
165
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
166
+ MCP_SERVER_URL: prefixedServerUrl,
167
+ MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
168
+ },
169
+ });
170
+ scopedServers.push(server);
171
+ const authMetadataRes = await fetch(`${mcpBaseUrl}/.well-known/oauth-authorization-server${issuerPath}`);
172
+ assert.strictEqual(authMetadataRes.status, 200, "Should return 200");
173
+ const authMetadata = (await authMetadataRes.json());
174
+ assert.strictEqual(authMetadata.issuer, prefixedServerUrl);
175
+ assert.strictEqual(authMetadata.authorization_endpoint, `${prefixedServerUrl}/authorize`);
176
+ assert.strictEqual(authMetadata.token_endpoint, `${prefixedServerUrl}/token`);
177
+ assert.strictEqual(authMetadata.registration_endpoint, `${prefixedServerUrl}/register`);
178
+ assert.strictEqual(authMetadata.revocation_endpoint, `${prefixedServerUrl}/revoke`);
179
+ const resourceMetadataRes = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource${issuerPath}/mcp`);
180
+ assert.strictEqual(resourceMetadataRes.status, 200, "Should return 200");
181
+ const resourceMetadata = (await resourceMetadataRes.json());
182
+ assert.strictEqual(resourceMetadata.resource, prefixedServerUrl);
183
+ assert.deepStrictEqual(resourceMetadata.authorization_servers, [prefixedServerUrl]);
184
+ console.log(" ✓ Path-prefixed discovery metadata returned at RFC well-known URLs");
185
+ }
186
+ finally {
187
+ cleanupServers(scopedServers);
188
+ await prefixedMockGitLab.stop();
189
+ }
190
+ });
142
191
  });
143
192
  // ---------------------------------------------------------------------------
144
193
  // Test suite: /mcp auth enforcement
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ts-node
2
- import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema, CreateIssueNoteSchema } from '../schemas.js';
2
+ import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema, CreateIssueNoteSchema, CreateMergeRequestEmojiReactionSchema, CreateIssueEmojiReactionSchema, DeleteMergeRequestEmojiReactionSchema, DeleteIssueEmojiReactionSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema } from '../schemas.js';
3
3
  function runGetFileContentsSchemaTests() {
4
4
  console.log('🧪 Testing GetFileContentsSchema...');
5
5
  const cases = [
@@ -371,13 +371,69 @@ function runCreateIssueNoteSchemaTests() {
371
371
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
372
372
  return { passed, failed };
373
373
  }
374
+ function runEmojiReactionSchemaTests() {
375
+ console.log('\n🧪 Testing Emoji Reaction Schemas...');
376
+ const cases = [
377
+ { name: 'schema:create_mr_emoji:valid', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42', name: 'thumbsup' }, expected: { project_id: 'my/project', merge_request_iid: '42', name: 'thumbsup' } },
378
+ { name: 'schema:create_mr_emoji:numeric-iid-coerced', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: 42, name: 'rocket' }, expected: { merge_request_iid: '42', name: 'rocket' } },
379
+ { name: 'schema:create_mr_emoji:reject-missing-name', schema: CreateMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42' }, shouldFail: true },
380
+ { name: 'schema:delete_mr_emoji:valid', schema: DeleteMergeRequestEmojiReactionSchema, input: { project_id: 'my/project', merge_request_iid: '42', award_id: '123' }, expected: { merge_request_iid: '42', award_id: '123' } },
381
+ { name: 'schema:create_issue_emoji:valid', schema: CreateIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10', name: 'thumbsdown' }, expected: { issue_iid: '10', name: 'thumbsdown' } },
382
+ { name: 'schema:create_issue_emoji:reject-missing-name', schema: CreateIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10' }, shouldFail: true },
383
+ { name: 'schema:delete_issue_emoji:valid', schema: DeleteIssueEmojiReactionSchema, input: { project_id: 'my/project', issue_iid: '10', award_id: '99' }, expected: { issue_iid: '10', award_id: '99' } },
384
+ { name: 'schema:create_work_item_emoji:valid', schema: CreateWorkItemEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, name: 'rocket' }, expected: { iid: 5, name: 'rocket' } },
385
+ { name: 'schema:create_work_item_emoji:reject-missing-name', schema: CreateWorkItemEmojiReactionSchema, input: { project_id: 'my/project', iid: 5 }, shouldFail: true },
386
+ { name: 'schema:create_work_item_note_emoji:valid', schema: CreateWorkItemNoteEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, note_id: 'gid://gitlab/Note/123', name: 'thumbsup' }, expected: { iid: 5, note_id: 'gid://gitlab/Note/123', name: 'thumbsup' } },
387
+ { name: 'schema:create_work_item_note_emoji:reject-missing-note-id', schema: CreateWorkItemNoteEmojiReactionSchema, input: { project_id: 'my/project', iid: 5, name: 'thumbsup' }, shouldFail: true },
388
+ ];
389
+ let passed = 0;
390
+ let failed = 0;
391
+ cases.forEach(testCase => {
392
+ const result = { name: testCase.name, status: 'failed' };
393
+ const parsed = testCase.schema.safeParse(testCase.input);
394
+ if (testCase.shouldFail) {
395
+ if (parsed.success) {
396
+ result.error = 'Expected schema validation to fail';
397
+ }
398
+ else {
399
+ result.status = 'passed';
400
+ }
401
+ }
402
+ else if (parsed.success) {
403
+ const expected = testCase.expected || {};
404
+ const matches = Object.entries(expected).every(([key, value]) => {
405
+ return parsed.data[key] === value;
406
+ });
407
+ if (matches) {
408
+ result.status = 'passed';
409
+ }
410
+ else {
411
+ result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
412
+ }
413
+ }
414
+ else {
415
+ result.error = parsed.error?.message || 'Schema validation failed';
416
+ }
417
+ if (result.status === 'passed') {
418
+ passed++;
419
+ console.log(`✅ ${result.name}`);
420
+ }
421
+ else {
422
+ failed++;
423
+ console.log(`❌ ${result.name}: ${result.error}`);
424
+ }
425
+ });
426
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
427
+ return { passed, failed };
428
+ }
374
429
  if (import.meta.url === `file://${process.argv[1]}`) {
375
430
  const getFileContentsResult = runGetFileContentsSchemaTests();
376
431
  const fileContentResult = runGitLabFileContentSchemaTests();
377
432
  const createPipelineResult = runCreatePipelineSchemaTests();
378
433
  const createIssueNoteResult = runCreateIssueNoteSchemaTests();
379
- const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed;
380
- const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed;
434
+ const emojiReactionResult = runEmojiReactionSchemaTests();
435
+ const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed + createIssueNoteResult.passed + emojiReactionResult.passed;
436
+ const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed + createIssueNoteResult.failed + emojiReactionResult.failed;
381
437
  console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
382
438
  if (totalFailed > 0) {
383
439
  process.exit(1);