@zereight/mcp-gitlab 2.0.35 → 2.1.0

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.
@@ -82,11 +82,16 @@ class GitLabOAuthServerProvider {
82
82
  _resourceName;
83
83
  _requiredScopes;
84
84
  _clientCache = new BoundedClientCache(CLIENT_CACHE_MAX_SIZE);
85
- constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly) {
85
+ constructor(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes) {
86
86
  this._gitlabBaseUrl = gitlabBaseUrl;
87
87
  this._gitlabAppId = gitlabAppId;
88
88
  this._resourceName = resourceName;
89
- this._requiredScopes = readOnly ? REQUIRED_GITLAB_SCOPES_RO : REQUIRED_GITLAB_SCOPES_RW;
89
+ this._requiredScopes =
90
+ customScopes && customScopes.length > 0
91
+ ? customScopes
92
+ : readOnly
93
+ ? REQUIRED_GITLAB_SCOPES_RO
94
+ : REQUIRED_GITLAB_SCOPES_RW;
90
95
  }
91
96
  // ---- Client store (local DCR) ------------------------------------------
92
97
  get clientsStore() {
@@ -251,7 +256,9 @@ class GitLabOAuthServerProvider {
251
256
  * @param gitlabBaseUrl Root URL of the GitLab instance (no trailing slash, no /api/v4).
252
257
  * @param gitlabAppId Client ID of the pre-registered GitLab OAuth application.
253
258
  * @param resourceName Human-readable name shown on the GitLab consent screen.
259
+ * @param readOnly When true and customScopes is not set, restricts to read_api scope.
260
+ * @param customScopes Explicit list of GitLab scopes to require. Overrides readOnly when set.
254
261
  */
255
- export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false) {
256
- return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly);
262
+ export function createGitLabOAuthProvider(gitlabBaseUrl, gitlabAppId, resourceName = "GitLab MCP Server", readOnly = false, customScopes) {
263
+ return new GitLabOAuthServerProvider(gitlabBaseUrl, gitlabAppId, resourceName, readOnly, customScopes);
257
264
  }
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(),
@@ -1116,10 +1129,10 @@ export const UpdateIssueNoteSchema = ProjectParamsSchema.extend({
1116
1129
  .refine(data => !(data.body !== undefined && data.resolved !== undefined), {
1117
1130
  message: "Only one of 'body' or 'resolved' can be provided, not both",
1118
1131
  });
1119
- // Input schema for adding a note to an existing issue discussion
1132
+ // Input schema for adding a note to an issue (top-level comment or discussion reply)
1120
1133
  export const CreateIssueNoteSchema = ProjectParamsSchema.extend({
1121
1134
  issue_iid: z.coerce.string().describe("The IID of an issue"),
1122
- discussion_id: z.coerce.string().describe("The ID of a thread"),
1135
+ discussion_id: z.coerce.string().optional().describe("The ID of a thread. If provided, replies to that thread; otherwise creates a top-level note"),
1123
1136
  body: z.string().describe("The content of the note or reply"),
1124
1137
  created_at: z.string().optional().describe("Date the note was created at (ISO 8601 format)"),
1125
1138
  });
@@ -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,11 +1681,11 @@ 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"),
@@ -1677,7 +1695,7 @@ export const ListProjectsSchema = z
1677
1695
  export const ListLabelsSchema = z.object({
1678
1696
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
1679
1697
  with_counts: z
1680
- .boolean()
1698
+ .coerce.boolean()
1681
1699
  .optional()
1682
1700
  .describe("Whether or not to include issue and merge request counts"),
1683
1701
  include_ancestor_groups: z.coerce.boolean().optional().describe("Include ancestor groups"),
@@ -1729,11 +1747,11 @@ export const ListGroupProjectsSchema = z
1729
1747
  .optional()
1730
1748
  .describe("Filter by project visibility"),
1731
1749
  with_issues_enabled: z
1732
- .boolean()
1750
+ .coerce.boolean()
1733
1751
  .optional()
1734
1752
  .describe("Filter projects with issues feature enabled"),
1735
1753
  with_merge_requests_enabled: z
1736
- .boolean()
1754
+ .coerce.boolean()
1737
1755
  .optional()
1738
1756
  .describe("Filter projects with merge requests feature enabled"),
1739
1757
  min_access_level: z.coerce.number().optional().describe("Filter by minimum access level"),
@@ -1785,7 +1803,7 @@ export const GitLabWikiPageSchema = z.object({
1785
1803
  export const ListGroupWikiPagesSchema = z
1786
1804
  .object({
1787
1805
  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"),
1806
+ with_content: z.coerce.boolean().optional().describe("Include content of the wiki pages"),
1789
1807
  })
1790
1808
  .merge(PaginationOptionsSchema);
1791
1809
  export const GetGroupWikiPageSchema = z.object({
@@ -1966,7 +1984,7 @@ export const CreateDraftNoteSchema = ProjectParamsSchema.extend({
1966
1984
  .describe("The ID of a discussion the draft note replies to"),
1967
1985
  position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1968
1986
  resolve_discussion: z
1969
- .boolean()
1987
+ .coerce.boolean()
1970
1988
  .optional()
1971
1989
  .describe("Whether to resolve the discussion when publishing"),
1972
1990
  });
@@ -1977,7 +1995,7 @@ export const UpdateDraftNoteSchema = ProjectParamsSchema.extend({
1977
1995
  body: z.string().optional().describe("The content of the draft note"),
1978
1996
  position: MergeRequestThreadPositionSchema.optional().describe("Position when creating a diff note"),
1979
1997
  resolve_discussion: z
1980
- .boolean()
1998
+ .coerce.boolean()
1981
1999
  .optional()
1982
2000
  .describe("Whether to resolve the discussion when publishing"),
1983
2001
  });
@@ -2085,7 +2103,7 @@ export const ListCommitsSchema = z.object({
2085
2103
  all: z.coerce.boolean().optional().describe("Retrieve every commit from the repository"),
2086
2104
  with_stats: z.coerce.boolean().optional().describe("Stats about each commit are added to the response"),
2087
2105
  first_parent: z
2088
- .boolean()
2106
+ .coerce.boolean()
2089
2107
  .optional()
2090
2108
  .describe("Follow only the first parent commit upon seeing a merge commit"),
2091
2109
  order: z.enum(["default", "topo"]).optional().describe("List commits in order"),
@@ -2102,7 +2120,7 @@ export const GetCommitDiffSchema = z.object({
2102
2120
  project_id: z.coerce.string().describe("Project ID or complete URL-encoded path to project"),
2103
2121
  sha: z.string().describe("The commit hash or name of a repository branch or tag"),
2104
2122
  full_diff: z
2105
- .boolean()
2123
+ .coerce.boolean()
2106
2124
  .optional()
2107
2125
  .describe("Whether to return the full diff or only first page (default: false)"),
2108
2126
  });
@@ -2145,7 +2163,7 @@ export const ListProjectMembersSchema = z.object({
2145
2163
  user_ids: z.array(z.coerce.number()).optional().describe("Filter by user IDs"),
2146
2164
  skip_users: z.array(z.coerce.number()).optional().describe("User IDs to exclude"),
2147
2165
  include_inheritance: z
2148
- .boolean()
2166
+ .coerce.boolean()
2149
2167
  .optional()
2150
2168
  .describe("Include inherited members. Defaults to false."),
2151
2169
  per_page: z.coerce.number().optional().describe("Number of items per page (default: 20, max: 100)"),
@@ -2216,11 +2234,11 @@ export const ListGroupIterationsSchema = z
2216
2234
  .optional()
2217
2235
  .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
2236
  include_ancestors: z
2219
- .boolean()
2237
+ .coerce.boolean()
2220
2238
  .optional()
2221
2239
  .describe("Include iterations for group and its ancestors. Defaults to true."),
2222
2240
  include_descendants: z
2223
- .boolean()
2241
+ .coerce.boolean()
2224
2242
  .optional()
2225
2243
  .describe("Include iterations for group and its descendants. Defaults to false."),
2226
2244
  updated_before: z
@@ -2419,7 +2437,7 @@ export const ListReleasesSchema = z
2419
2437
  .optional()
2420
2438
  .describe("The direction of the order. Either desc (default) for descending order or asc for ascending order."),
2421
2439
  include_html_description: z
2422
- .boolean()
2440
+ .coerce.boolean()
2423
2441
  .optional()
2424
2442
  .describe("If true, a response includes HTML rendered Markdown of the release description."),
2425
2443
  })
@@ -2428,7 +2446,7 @@ export const GetReleaseSchema = z.object({
2428
2446
  project_id: z.coerce.string().describe("Project ID or URL-encoded path"),
2429
2447
  tag_name: z.string().describe("The Git tag the release is associated with"),
2430
2448
  include_html_description: z
2431
- .boolean()
2449
+ .coerce.boolean()
2432
2450
  .optional()
2433
2451
  .describe("If true, a response includes HTML rendered Markdown of the release description."),
2434
2452
  });
@@ -2511,7 +2529,7 @@ export const ListJobArtifactsSchema = z.object({
2511
2529
  .optional()
2512
2530
  .describe("Directory path within the artifacts archive (defaults to root)"),
2513
2531
  recursive: z
2514
- .boolean()
2532
+ .coerce.boolean()
2515
2533
  .optional()
2516
2534
  .describe("Whether to list artifacts recursively"),
2517
2535
  });
@@ -2593,8 +2611,8 @@ export const CreateWorkItemSchema = z.object({
2593
2611
  .default("issue")
2594
2612
  .describe("Type of work item to create. Defaults to 'issue'."),
2595
2613
  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"),
2614
+ labels: coerceStringArray.optional().describe("Array of label names to assign"),
2615
+ assignee_usernames: coerceStringArray.optional().describe("Array of usernames to assign"),
2598
2616
  parent_iid: z.coerce.number().optional().describe("IID of the parent work item to set hierarchy"),
2599
2617
  weight: z.coerce.number().optional().describe("Weight of the work item"),
2600
2618
  health_status: z.enum(["onTrack", "needsAttention", "atRisk"]).optional().describe("Set health status"),
@@ -2607,9 +2625,9 @@ export const CreateWorkItemSchema = z.object({
2607
2625
  export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
2608
2626
  title: z.string().optional().describe("New title"),
2609
2627
  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)"),
2628
+ add_labels: coerceStringArray.optional().describe("Label names to add"),
2629
+ remove_labels: coerceStringArray.optional().describe("Label names to remove"),
2630
+ assignee_usernames: coerceStringArray.optional().describe("Set assignees by username (replaces existing)"),
2613
2631
  state_event: z.enum(["close", "reopen"]).optional().describe("Close or reopen the work item"),
2614
2632
  weight: z.coerce.number().optional().describe("Set weight (issues, tasks, epics only)"),
2615
2633
  status: z.string().optional().describe("Set status by ID. Use list_work_item_statuses to get available status IDs."),
@@ -2617,11 +2635,11 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
2617
2635
  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
2636
  remove_parent: z.coerce.boolean().optional().describe("Set to true to remove the parent from hierarchy"),
2619
2637
  children_to_add: z.array(z.object({
2620
- project_id: z.coerce.string().describe("Project ID or path of the child work item"),
2638
+ 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
2639
  iid: z.coerce.number().describe("IID of the child work item"),
2622
2640
  })).optional().describe("Array of children to add to this work item's hierarchy"),
2623
2641
  children_to_remove: z.array(z.object({
2624
- project_id: z.coerce.string().describe("Project ID or path of the child work item"),
2642
+ 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
2643
  iid: z.coerce.number().describe("IID of the child work item"),
2626
2644
  })).optional().describe("Array of children to remove from this work item's hierarchy"),
2627
2645
  health_status: z.enum(["onTrack", "needsAttention", "atRisk"]).optional().describe("Set health status on issues and epics"),
@@ -2631,12 +2649,12 @@ export const UpdateWorkItemSchema = WorkItemParamsSchema.extend({
2631
2649
  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
2650
  confidential: z.coerce.boolean().optional().describe("Set confidentiality"),
2633
2651
  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"),
2652
+ 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
2653
  iid: z.coerce.number().describe("IID of the work item to link"),
2636
2654
  link_type: z.enum(["RELATED", "BLOCKED_BY", "BLOCKS"]).optional().default("RELATED").describe("Link type: RELATED, BLOCKED_BY, or BLOCKS. Defaults to RELATED."),
2637
2655
  })).optional().describe("Work items to link"),
2638
2656
  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"),
2657
+ 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
2658
  iid: z.coerce.number().describe("IID of the linked work item to remove"),
2641
2659
  })).optional().describe("Linked work items to remove"),
2642
2660
  custom_fields: z.array(z.object({
@@ -2740,7 +2758,7 @@ export const ListWebhookEventsSchema = z
2740
2758
  .optional()
2741
2759
  .describe("Filter by response status code (e.g. 200, 500) or category: successful, client_failure, server_failure"),
2742
2760
  summary: z
2743
- .boolean()
2761
+ .coerce.boolean()
2744
2762
  .optional()
2745
2763
  .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
2764
  per_page: z
@@ -15,6 +15,8 @@ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server
15
15
  // ---------------------------------------------------------------------------
16
16
  const MOCK_OAUTH_TOKEN = "ya29.mock-oauth-token-abcdef123456";
17
17
  const MOCK_CLIENT_ID = "mock-app-uid-from-dcr";
18
+ const MOCK_PAT_TOKEN = "glpat-mockpat-testtoken-abcdef12"; // ≥20 chars, valid charset
19
+ const MOCK_JOB_TOKEN = "mockjobtoken-testenv-1234567890"; // ≥20 chars, valid charset
18
20
  const MOCK_GITLAB_PORT_BASE = 9200;
19
21
  const MCP_SERVER_PORT_BASE = 3200;
20
22
  // ---------------------------------------------------------------------------
@@ -441,3 +443,110 @@ describe("MCP OAuth — createGitLabOAuthProvider", () => {
441
443
  console.log(` ✓ client_name annotated: ${registered.client_name}`);
442
444
  });
443
445
  });
446
+ // ---------------------------------------------------------------------------
447
+ // Test suite: Header auth fallback within GITLAB_MCP_OAUTH mode
448
+ // ---------------------------------------------------------------------------
449
+ describe("MCP OAuth — Header Auth Fallback", () => {
450
+ let mcpUrl;
451
+ let mcpBaseUrl;
452
+ let mockGitLab;
453
+ let servers = [];
454
+ before(async () => {
455
+ const mockPort = await findMockServerPort(MOCK_GITLAB_PORT_BASE + 150);
456
+ mockGitLab = new MockGitLabServer({
457
+ port: mockPort,
458
+ // Include both OAuth token and raw tokens so GitLab API calls succeed
459
+ validTokens: [MOCK_OAUTH_TOKEN, MOCK_PAT_TOKEN, MOCK_JOB_TOKEN],
460
+ });
461
+ await mockGitLab.start();
462
+ const mockGitLabUrl = mockGitLab.getUrl();
463
+ // OAuth endpoints needed for server startup AS metadata
464
+ addOAuthEndpoints(mockGitLab, MOCK_OAUTH_TOKEN, MOCK_CLIENT_ID, mockGitLabUrl);
465
+ const mcpPort = await findAvailablePort(MCP_SERVER_PORT_BASE + 150);
466
+ mcpBaseUrl = `http://${HOST}:${mcpPort}`;
467
+ mcpUrl = `${mcpBaseUrl}/mcp`;
468
+ const server = await launchServer({
469
+ mode: TransportMode.STREAMABLE_HTTP,
470
+ port: mcpPort,
471
+ timeout: 5000,
472
+ env: {
473
+ STREAMABLE_HTTP: "true",
474
+ GITLAB_MCP_OAUTH: "true",
475
+ GITLAB_OAUTH_APP_ID: "test-oauth-app-id",
476
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
477
+ MCP_SERVER_URL: mcpBaseUrl,
478
+ MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL: "true",
479
+ },
480
+ });
481
+ servers.push(server);
482
+ console.log(`Mock GitLab (header-auth): ${mockGitLabUrl}`);
483
+ console.log(`MCP Server (header-auth): ${mcpBaseUrl}`);
484
+ });
485
+ after(async () => {
486
+ cleanupServers(servers);
487
+ if (mockGitLab) {
488
+ await mockGitLab.stop();
489
+ }
490
+ });
491
+ const initBody = JSON.stringify({
492
+ jsonrpc: "2.0",
493
+ id: 1,
494
+ method: "initialize",
495
+ params: {
496
+ protocolVersion: "2024-11-05",
497
+ capabilities: {},
498
+ clientInfo: { name: "test-client", version: "1.0.0" },
499
+ },
500
+ });
501
+ test("POST /mcp with Private-Token header bypasses OAuth and is accepted", async () => {
502
+ const res = await fetch(mcpUrl, {
503
+ method: "POST",
504
+ headers: {
505
+ "Content-Type": "application/json",
506
+ Accept: "application/json, text/event-stream",
507
+ "Private-Token": MOCK_PAT_TOKEN,
508
+ },
509
+ body: initBody,
510
+ });
511
+ assert.notStrictEqual(res.status, 401, "Should not return 401 with valid Private-Token");
512
+ assert.notStrictEqual(res.status, 403, "Should not return 403 with valid Private-Token");
513
+ console.log(` ✓ Private-Token header accepted (status: ${res.status})`);
514
+ });
515
+ test("POST /mcp with JOB-TOKEN header bypasses OAuth and is accepted", async () => {
516
+ const res = await fetch(mcpUrl, {
517
+ method: "POST",
518
+ headers: {
519
+ "Content-Type": "application/json",
520
+ Accept: "application/json, text/event-stream",
521
+ "job-token": MOCK_JOB_TOKEN,
522
+ },
523
+ body: initBody,
524
+ });
525
+ assert.notStrictEqual(res.status, 401, "Should not return 401 with valid JOB-TOKEN");
526
+ assert.notStrictEqual(res.status, 403, "Should not return 403 with valid JOB-TOKEN");
527
+ console.log(` ✓ JOB-TOKEN header accepted (status: ${res.status})`);
528
+ });
529
+ test("POST /mcp with valid OAuth Bearer token still works normally", async () => {
530
+ const res = await fetch(mcpUrl, {
531
+ method: "POST",
532
+ headers: {
533
+ "Content-Type": "application/json",
534
+ Accept: "application/json, text/event-stream",
535
+ Authorization: `Bearer ${MOCK_OAUTH_TOKEN}`,
536
+ },
537
+ body: initBody,
538
+ });
539
+ assert.notStrictEqual(res.status, 401, "OAuth Bearer should still work alongside header auth");
540
+ assert.notStrictEqual(res.status, 403, "Should not return 403 with valid OAuth token");
541
+ console.log(` ✓ OAuth Bearer token still accepted (status: ${res.status})`);
542
+ });
543
+ test("POST /mcp with no auth still returns 401", async () => {
544
+ const res = await fetch(mcpUrl, {
545
+ method: "POST",
546
+ headers: { "Content-Type": "application/json" },
547
+ body: initBody,
548
+ });
549
+ assert.strictEqual(res.status, 401, "Should return 401 with no auth");
550
+ console.log(" ✓ No auth still returns 401");
551
+ });
552
+ });
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ts-node
2
- import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema } from '../schemas.js';
2
+ import { GetFileContentsSchema, GitLabFileContentSchema, CreatePipelineSchema, CreateIssueNoteSchema } from '../schemas.js';
3
3
  function runGetFileContentsSchemaTests() {
4
4
  console.log('🧪 Testing GetFileContentsSchema...');
5
5
  const cases = [
@@ -298,12 +298,86 @@ function runCreatePipelineSchemaTests() {
298
298
  console.log(`\nResults: ${passed} passed, ${failed} failed`);
299
299
  return { passed, failed };
300
300
  }
301
+ function runCreateIssueNoteSchemaTests() {
302
+ console.log('\n🧪 Testing CreateIssueNoteSchema...');
303
+ const cases = [
304
+ {
305
+ name: 'schema:create_issue_note:top-level-note-without-discussion-id',
306
+ input: { project_id: 'my/project', issue_iid: '42', body: 'A comment' },
307
+ expected: { project_id: 'my/project', issue_iid: '42', body: 'A comment', discussion_id: undefined }
308
+ },
309
+ {
310
+ name: 'schema:create_issue_note:reply-with-discussion-id',
311
+ input: { project_id: 'my/project', issue_iid: '42', discussion_id: 'abc123', body: 'A reply' },
312
+ expected: { project_id: 'my/project', issue_iid: '42', discussion_id: 'abc123', body: 'A reply' }
313
+ },
314
+ {
315
+ name: 'schema:create_issue_note:with-created-at',
316
+ input: { project_id: 'my/project', issue_iid: '7', body: 'Note', created_at: '2025-01-01T00:00:00Z' },
317
+ expected: { project_id: 'my/project', issue_iid: '7', body: 'Note', created_at: '2025-01-01T00:00:00Z' }
318
+ },
319
+ {
320
+ name: 'schema:create_issue_note:numeric-issue-iid-coerced',
321
+ input: { project_id: 'my/project', issue_iid: 99, body: 'Coerced' },
322
+ expected: { project_id: 'my/project', issue_iid: '99', body: 'Coerced' }
323
+ },
324
+ {
325
+ name: 'schema:create_issue_note:reject-missing-body',
326
+ input: { project_id: 'my/project', issue_iid: '1' },
327
+ shouldFail: true
328
+ }
329
+ ];
330
+ let passed = 0;
331
+ let failed = 0;
332
+ cases.forEach(testCase => {
333
+ const result = {
334
+ name: testCase.name,
335
+ status: 'failed'
336
+ };
337
+ const parsed = CreateIssueNoteSchema.safeParse(testCase.input);
338
+ if (testCase.shouldFail) {
339
+ if (parsed.success) {
340
+ result.error = 'Expected schema validation to fail';
341
+ }
342
+ else {
343
+ result.status = 'passed';
344
+ }
345
+ }
346
+ else if (parsed.success) {
347
+ const expected = testCase.expected || {};
348
+ const matches = Object.entries(expected).every(([key, value]) => {
349
+ const actual = parsed.data[key];
350
+ return actual === value;
351
+ });
352
+ if (matches) {
353
+ result.status = 'passed';
354
+ }
355
+ else {
356
+ result.error = `Unexpected parsed result: ${JSON.stringify(parsed.data)}`;
357
+ }
358
+ }
359
+ else {
360
+ result.error = parsed.error?.message || 'Schema validation failed';
361
+ }
362
+ if (result.status === 'passed') {
363
+ passed++;
364
+ console.log(`✅ ${result.name}`);
365
+ }
366
+ else {
367
+ failed++;
368
+ console.log(`❌ ${result.name}: ${result.error}`);
369
+ }
370
+ });
371
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
372
+ return { passed, failed };
373
+ }
301
374
  if (import.meta.url === `file://${process.argv[1]}`) {
302
375
  const getFileContentsResult = runGetFileContentsSchemaTests();
303
376
  const fileContentResult = runGitLabFileContentSchemaTests();
304
377
  const createPipelineResult = runCreatePipelineSchemaTests();
305
- const totalPassed = getFileContentsResult.passed + fileContentResult.passed + createPipelineResult.passed;
306
- const totalFailed = getFileContentsResult.failed + fileContentResult.failed + createPipelineResult.failed;
378
+ 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;
307
381
  console.log(`\nTotal Results: ${totalPassed} passed, ${totalFailed} failed`);
308
382
  if (totalFailed > 0) {
309
383
  process.exit(1);