@zereight/mcp-gitlab 2.1.67 → 2.1.68

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/index.js CHANGED
@@ -114,7 +114,7 @@ GitLabDiscussionNoteSchema, // Added
114
114
  GitLabDiscussionSchema,
115
115
  // Draft Notes Schemas
116
116
  GitLabDraftNoteSchema, GitLabForkSchema, GitLabBranchSchema, GitLabProtectedBranchSchema, GitLabGroupSchema, GitLabIssueLinkSchema, GitLabIssueSchema, GitLabIssueWithLinkDetailsSchema, GitLabMarkdownUploadSchema, GitLabMergeRequestPipelineSchema, GitLabMergeRequestSchema, GitLabMilestonesSchema, GitLabGroupMilestonesSchema, GitLabNamespaceExistsResponseSchema, GitLabNamespaceSchema, GitLabPipelineJobSchema, GitLabDeploymentSchema, GitLabEnvironmentSchema, GitLabPipelineSchema, GitLabPipelineScheduleSchema, GitLabPipelineScheduleVariableSchema, GitLabPipelineSchedulePlayResultSchema, ListPipelineSchedulesSchema, GetPipelineScheduleSchema, ListPipelineSchedulePipelinesSchema, CreatePipelineScheduleSchema, UpdatePipelineScheduleSchema, DeletePipelineScheduleSchema, PlayPipelineScheduleSchema, TakeOwnershipPipelineScheduleSchema, GetPipelineScheduleVariableSchema, CreatePipelineScheduleVariableSchema, UpdatePipelineScheduleVariableSchema, DeletePipelineScheduleVariableSchema, GitLabPipelineTriggerJobSchema, GitLabProjectMemberSchema, GitLabProjectSchema, GitLabTodoSchema, GitLabReferenceSchema, GitLabRepositorySchema, GitLabSearchBlobResultSchema, GitLabSearchResponseSchema, GitLabTreeItemSchema, GitLabUserSchema, GitLabUsersResponseSchema, GitLabWikiPageSchema, GroupIteration, ListCommitStatusesSchema, ListBranchesSchema, ListCommitsSchema, ListDraftNotesSchema, ListGroupIterationsSchema, ListGroupProjectsSchema, GitLabCiVariableSchema, ListProjectVariablesSchema, GetProjectVariableSchema, CreateProjectVariableSchema, UpdateProjectVariableSchema, DeleteProjectVariableSchema, ListGroupVariablesSchema, GetGroupVariableSchema, CreateGroupVariableSchema, UpdateGroupVariableSchema, DeleteGroupVariableSchema, GitLabDependencyProxySchema, GitLabDependencyProxyBlobSchema, GetDependencyProxySettingsSchema, UpdateDependencyProxySettingsSchema, ListDependencyProxyBlobsSchema, PurgeDependencyProxyCacheSchema, ListProjectVulnerabilitiesSchema, GetVulnerabilitySchema, DismissVulnerabilitySchema, ConfirmVulnerabilitySchema, ListIssueDiscussionsSchema, ListIssueLinksSchema, ListIssuesSchema, ListTodosSchema, ListLabelsSchema, ListMergeRequestDiffsSchema, // Added
117
- GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, GetMergeRequestDiscussionSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, CreateWebhookSchema, UpdateWebhookSchema, DeleteWebhookSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
117
+ GetMergeRequestFileDiffSchema, ListMergeRequestChangedFilesSchema, ListMergeRequestDiscussionsSchema, ListMergeRequestPipelinesSchema, ListMergeRequestsSchema, ListGroupMergeRequestsSchema, ListMergeRequestVersionsSchema, GetMergeRequestVersionSchema, GitLabMergeRequestVersionSchema, GitLabMergeRequestVersionDetailSchema, ListNamespacesSchema, ListPipelineJobsSchema, ListPipelinesSchema, ListDeploymentsSchema, ListEnvironmentsSchema, ListPipelineTriggerJobsSchema, ValidateCiLintSchema, ValidateProjectCiLintSchema, ListCiCatalogResourcesSchema, GetCiCatalogResourceSchema, ListProjectMembersSchema, ListGroupMembersSchema, ListProjectMilestonesSchema, ListGroupMilestonesSchema, ListProjectsSchema, ListWikiPagesSchema, GetGroupWikiPageSchema, ListGroupWikiPagesSchema, UpdateGroupWikiPageSchema, MarkdownUploadSchema, MarkdownUploadRemoteSchema, DownloadAttachmentSchema, DownloadJobArtifactsSchema, GetJobArtifactFileSchema, GitLabArtifactEntrySchema, ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema, GetMergeRequestApprovalStateSchema, GetMergeRequestConflictsSchema, GitLabMergeRequestApprovalsResponseSchema, GitLabMergeRequestApprovalStateSchema, MyIssuesSchema, OrbitQuerySchema, OrbitSchemaSchema, OrbitStatusSchema, OrbitToolsSchema, MarkAllTodosDoneSchema, MarkTodoDoneSchema, PaginatedDiscussionsResponseSchema, PromoteProjectMilestoneSchema, PublishDraftNoteSchema, PlayPipelineJobSchema, PushFilesSchema, RetryPipelineJobSchema, RetryPipelineSchema, SearchCodeSchema, SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, UpdateDraftNoteSchema, UpdateIssueNoteSchema, UpdateIssueSchema, UpdateIssueDescriptionPatchSchema, UpdateLabelSchema, UpdateProjectSchema, UpdateMergeRequestNoteSchema, UpdateMergeRequestDiscussionNoteSchema, UpdateMergeRequestSchema, UpdateWikiPageSchema, VerifyNamespaceSchema, GitLabEventSchema, ListEventsSchema, GetProjectEventsSchema, ExecuteGraphQLSchema, GitLabReleaseSchema, ListReleasesSchema, GetReleaseSchema, CreateReleaseSchema, UpdateReleaseSchema, DeleteReleaseSchema, CreateReleaseEvidenceSchema, DownloadReleaseAssetSchema, ListTagsSchema, GetTagSchema, CreateTagSchema, DeleteTagSchema, GetTagSignatureSchema, GitLabTagSchema, GitLabTagSignatureSchema, ListSnippetsSchema, GetSnippetSchema, CreateSnippetSchema, UpdateSnippetSchema, DeleteSnippetSchema, GitLabSnippetSchema, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, GetMergeRequestDiscussionSchema, DeleteMergeRequestDiscussionNoteSchema, ResolveMergeRequestThreadSchema, GetWorkItemSchema, ListWorkItemsSchema, CreateWorkItemSchema, UpdateWorkItemSchema, ConvertWorkItemTypeSchema, ListWorkItemStatusesSchema, ListWorkItemNotesSchema, CreateWorkItemNoteSchema, CreateWorkItemEmojiReactionSchema, CreateWorkItemNoteEmojiReactionSchema, ListWorkItemEmojiReactionsSchema, ListWorkItemNoteEmojiReactionsSchema, DeleteWorkItemEmojiReactionSchema, DeleteWorkItemNoteEmojiReactionSchema, MoveWorkItemSchema, ListCustomFieldDefinitionsSchema, GetTimelineEventsSchema, CreateTimelineEventSchema, ListWebhooksSchema, CreateWebhookSchema, UpdateWebhookSchema, DeleteWebhookSchema, ListWebhookEventsSchema, GetWebhookEventSchema, HealthCheckSchema, } from "./schemas.js";
118
118
  import { randomUUID, timingSafeEqual, } from "node:crypto";
119
119
  import { createLogger } from "./utils/logger.js";
120
120
  const logger = createLogger();
@@ -7605,6 +7605,163 @@ async function executeGitLabGraphQL(query, variables = {}) {
7605
7605
  clearTimeout(timeout);
7606
7606
  }
7607
7607
  }
7608
+ /**
7609
+ * Build the snippets endpoint URL.
7610
+ *
7611
+ * When projectId is provided, or when a project allowlist is in effect (env
7612
+ * GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS or a session scope narrowed via
7613
+ * X-GitLab-Allowed-Project-Ids), returns the project snippets endpoint — routing
7614
+ * through getEffectiveProjectId so the effective scope is enforced. Only falls
7615
+ * back to the personal /snippets endpoint when no scope is active at all.
7616
+ */
7617
+ function getSnippetsEndpoint(projectId) {
7618
+ const scopeActive = getEffectiveAllowedProjectIds().length > 0 || !!GITLAB_PROJECT_ID;
7619
+ if (projectId || scopeActive) {
7620
+ const decoded = projectId ? decodeURIComponent(projectId) : "";
7621
+ const effectiveProjectId = getEffectiveProjectId(decoded);
7622
+ return `${getEffectiveApiUrl()}/projects/${encodeGitLabPathSegment(effectiveProjectId)}/snippets`;
7623
+ }
7624
+ return `${getEffectiveApiUrl()}/snippets`;
7625
+ }
7626
+ /**
7627
+ * List snippets — project snippets if projectId is given, otherwise personal snippets.
7628
+ */
7629
+ async function listSnippets(projectId, options = {}) {
7630
+ const url = new URL(getSnippetsEndpoint(projectId));
7631
+ Object.entries(options).forEach(([key, value]) => {
7632
+ if (value !== undefined) {
7633
+ url.searchParams.append(key, String(value));
7634
+ }
7635
+ });
7636
+ const response = await fetch(url.toString(), {
7637
+ ...getFetchConfig(),
7638
+ });
7639
+ await handleGitLabError(response);
7640
+ const data = await response.json();
7641
+ return GitLabSnippetSchema.array().parse(data);
7642
+ }
7643
+ /**
7644
+ * Get a snippet's metadata. Use getSnippetRawContent to fetch the raw file content.
7645
+ */
7646
+ async function getSnippet(projectId, snippetId) {
7647
+ const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}`, {
7648
+ ...getFetchConfig(),
7649
+ });
7650
+ await handleGitLabError(response);
7651
+ const data = await response.json();
7652
+ return GitLabSnippetSchema.parse(data);
7653
+ }
7654
+ /**
7655
+ * Get the raw content of a single-file snippet via the `/raw` endpoint.
7656
+ * For multi-file snippets, use getSnippetFileRawContent instead.
7657
+ */
7658
+ async function getSnippetRawContent(projectId, snippetId) {
7659
+ const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}/raw`, {
7660
+ ...getFetchConfig(),
7661
+ });
7662
+ await handleGitLabError(response);
7663
+ return await response.text();
7664
+ }
7665
+ /**
7666
+ * Extract the ref (branch/tag/commit) from a snippet file's raw_url.
7667
+ * Anchors on /snippets/{id}/raw/ so branch names or file paths containing
7668
+ * the word "raw" don't produce a false match.
7669
+ */
7670
+ function extractSnippetRef(rawUrl, snippetId, filePath) {
7671
+ const rawMarker = `/snippets/${snippetId}/raw/`;
7672
+ const decoded = decodeURIComponent(new URL(rawUrl).pathname);
7673
+ const markerIdx = decoded.indexOf(rawMarker);
7674
+ if (markerIdx === -1) {
7675
+ throw new Error(`Cannot extract ref from snippet file raw_url: ${rawUrl}`);
7676
+ }
7677
+ const afterRaw = decoded.slice(markerIdx + rawMarker.length); // "{ref}/{filePath}"
7678
+ const fileStart = afterRaw.lastIndexOf("/" + filePath);
7679
+ if (fileStart === -1) {
7680
+ throw new Error(`Cannot locate file path "${filePath}" in snippet file raw_url: ${rawUrl}`);
7681
+ }
7682
+ return afterRaw.slice(0, fileStart);
7683
+ }
7684
+ /**
7685
+ * Fetch the raw content of one file inside a multi-file snippet.
7686
+ * Accepts an explicit ref (branch/tag/commit) — callers resolve it via
7687
+ * extractSnippetRef or a user-supplied parameter before calling this.
7688
+ */
7689
+ async function getSnippetFileRawContent(projectId, snippetId, ref, filePath) {
7690
+ const encodedRef = encodeGitLabPathSegment(ref);
7691
+ const encodedPath = encodeGitLabPathSegment(filePath);
7692
+ const url = `${getSnippetsEndpoint(projectId)}/${snippetId}/files/${encodedRef}/${encodedPath}/raw`;
7693
+ const response = await fetch(url, { ...getFetchConfig() });
7694
+ await handleGitLabError(response);
7695
+ return await response.text();
7696
+ }
7697
+ function snippetCreateFilesPayload(options) {
7698
+ if (options.files !== undefined && options.files.length > 0) {
7699
+ return options.files;
7700
+ }
7701
+ if (options.file_name !== undefined && options.content !== undefined) {
7702
+ return [{ file_path: options.file_name, content: options.content }];
7703
+ }
7704
+ throw new Error("Provide either files[] (multi-file) or both file_name and content (single-file)");
7705
+ }
7706
+ /**
7707
+ * Create a snippet — project-scoped if projectId is given, otherwise personal.
7708
+ */
7709
+ async function createSnippet(projectId, options) {
7710
+ const { title, description, visibility } = options;
7711
+ const body = {
7712
+ title,
7713
+ visibility: visibility ?? "private",
7714
+ files: snippetCreateFilesPayload(options),
7715
+ };
7716
+ if (description !== undefined) {
7717
+ body.description = description;
7718
+ }
7719
+ const response = await fetch(getSnippetsEndpoint(projectId), {
7720
+ ...getFetchConfig(),
7721
+ method: "POST",
7722
+ body: JSON.stringify(body),
7723
+ });
7724
+ await handleGitLabError(response);
7725
+ const data = await response.json();
7726
+ return GitLabSnippetSchema.parse(data);
7727
+ }
7728
+ /**
7729
+ * Update an existing snippet — project-scoped if projectId is given, otherwise personal.
7730
+ */
7731
+ async function updateSnippet(projectId, options) {
7732
+ const { snippet_id, title, file_name, content, files, description, visibility } = options;
7733
+ const body = {};
7734
+ if (title !== undefined)
7735
+ body.title = title;
7736
+ if (description !== undefined)
7737
+ body.description = description;
7738
+ if (visibility !== undefined)
7739
+ body.visibility = visibility;
7740
+ if (files !== undefined) {
7741
+ body.files = files;
7742
+ }
7743
+ else if (file_name !== undefined && content !== undefined) {
7744
+ body.files = [{ action: "update", file_path: file_name, content }];
7745
+ }
7746
+ const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippet_id}`, {
7747
+ ...getFetchConfig(),
7748
+ method: "PUT",
7749
+ body: JSON.stringify(body),
7750
+ });
7751
+ await handleGitLabError(response);
7752
+ const data = await response.json();
7753
+ return GitLabSnippetSchema.parse(data);
7754
+ }
7755
+ /**
7756
+ * Delete a snippet — project-scoped if projectId is given, otherwise personal.
7757
+ */
7758
+ async function deleteSnippet(projectId, snippetId) {
7759
+ const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}`, {
7760
+ ...getFetchConfig(),
7761
+ method: "DELETE",
7762
+ });
7763
+ await handleGitLabError(response);
7764
+ }
7608
7765
  // Request handlers are now registered inside createServer() factory function
7609
7766
  // to ensure each transport connection gets its own Server instance (GHSA-345p-7cg4-v4c7).
7610
7767
  async function handleToolCall(params) {
@@ -10129,6 +10286,82 @@ async function handleToolCall(params) {
10129
10286
  content: [{ type: "text", text: JSON.stringify(signature) }],
10130
10287
  };
10131
10288
  }
10289
+ case "list_snippets": {
10290
+ const args = ListSnippetsSchema.parse(params.arguments);
10291
+ const { project_id, ...options } = args;
10292
+ const snippets = await listSnippets(project_id, options);
10293
+ return {
10294
+ content: [{ type: "text", text: JSON.stringify(snippets, null, 2) }],
10295
+ };
10296
+ }
10297
+ case "get_snippet": {
10298
+ const args = GetSnippetSchema.parse(params.arguments);
10299
+ const snippet = await getSnippet(args.project_id, args.snippet_id);
10300
+ const result = { ...snippet };
10301
+ if (args.include_content) {
10302
+ const files = snippet.files ?? [];
10303
+ if (files.length > 1) {
10304
+ const firstFile = files[0];
10305
+ let ref;
10306
+ if (args.ref !== undefined) {
10307
+ ref = args.ref;
10308
+ }
10309
+ else {
10310
+ if (!firstFile.raw_url)
10311
+ throw new Error(`Snippet file "${firstFile.path}" has no raw_url`);
10312
+ ref = extractSnippetRef(firstFile.raw_url, args.snippet_id, firstFile.path);
10313
+ }
10314
+ result.files = await Promise.all(files.map(async (f) => ({
10315
+ ...f,
10316
+ content: await getSnippetFileRawContent(args.project_id, args.snippet_id, ref, f.path),
10317
+ })));
10318
+ }
10319
+ else {
10320
+ const file = files[0];
10321
+ const filePath = file?.path ?? snippet.file_name ?? undefined;
10322
+ if (args.ref !== undefined && filePath) {
10323
+ result.content = await getSnippetFileRawContent(args.project_id, args.snippet_id, args.ref, filePath);
10324
+ }
10325
+ else {
10326
+ result.content = await getSnippetRawContent(args.project_id, args.snippet_id);
10327
+ }
10328
+ }
10329
+ }
10330
+ return {
10331
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
10332
+ };
10333
+ }
10334
+ case "create_snippet": {
10335
+ const args = CreateSnippetSchema.parse(params.arguments);
10336
+ const { project_id, ...options } = args;
10337
+ const snippet = await createSnippet(project_id, options);
10338
+ return {
10339
+ content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }],
10340
+ };
10341
+ }
10342
+ case "update_snippet": {
10343
+ const args = UpdateSnippetSchema.parse(params.arguments);
10344
+ const { project_id, ...options } = args;
10345
+ const snippet = await updateSnippet(project_id, options);
10346
+ return {
10347
+ content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }],
10348
+ };
10349
+ }
10350
+ case "delete_snippet": {
10351
+ const args = DeleteSnippetSchema.parse(params.arguments);
10352
+ await deleteSnippet(args.project_id, args.snippet_id);
10353
+ return {
10354
+ content: [
10355
+ {
10356
+ type: "text",
10357
+ text: JSON.stringify({
10358
+ status: "success",
10359
+ message: `Snippet ${args.snippet_id} deleted successfully`,
10360
+ }, null, 2),
10361
+ },
10362
+ ],
10363
+ };
10364
+ }
10132
10365
  case "list_webhooks": {
10133
10366
  const args = ListWebhooksSchema.parse(params.arguments);
10134
10367
  const webhooks = await listWebhooks(args);
package/build/schemas.js CHANGED
@@ -3847,6 +3847,193 @@ export const GitLabTagSchema = z.object({
3847
3847
  protected: z.boolean(),
3848
3848
  created_at: z.string().nullable(),
3849
3849
  });
3850
+ // --- Snippet schemas ---
3851
+ export const ListSnippetsSchema = z
3852
+ .object({
3853
+ project_id: z
3854
+ .coerce.string()
3855
+ .optional()
3856
+ .describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
3857
+ })
3858
+ .merge(PaginationOptionsSchema);
3859
+ export const GetSnippetSchema = z.object({
3860
+ project_id: z
3861
+ .coerce.string()
3862
+ .optional()
3863
+ .describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
3864
+ snippet_id: z.coerce.number().describe("The snippet ID"),
3865
+ include_content: coerceBooleanString
3866
+ .optional()
3867
+ .default(false)
3868
+ .describe("Whether to fetch the raw file content (default: false)"),
3869
+ ref: z
3870
+ .string()
3871
+ .optional()
3872
+ .describe("Branch, tag, or commit to fetch content from. Inferred from the snippet's raw_url when omitted."),
3873
+ });
3874
+ export const SnippetFileSchema = z.object({
3875
+ file_path: z.string().describe("File path within the snippet (e.g., 'src/script.py')"),
3876
+ content: z.string().describe("File content"),
3877
+ });
3878
+ export const SnippetFileUpdateActionSchema = z
3879
+ .object({
3880
+ action: z
3881
+ .enum(["create", "update", "delete", "move"])
3882
+ .describe("Type of action to perform on the file"),
3883
+ file_path: z
3884
+ .string()
3885
+ .optional()
3886
+ .describe("File path of the snippet file (required for all actions). For 'move', this is the new path; for 'create'/'update'/'delete', the target path."),
3887
+ previous_path: z
3888
+ .string()
3889
+ .optional()
3890
+ .describe("Previous path of the snippet file (required for 'move')."),
3891
+ content: z
3892
+ .string()
3893
+ .optional()
3894
+ .describe("Content of the snippet file (required for 'create' and 'update')."),
3895
+ })
3896
+ .superRefine((data, ctx) => {
3897
+ if (data.file_path === undefined) {
3898
+ ctx.addIssue({
3899
+ code: z.ZodIssueCode.custom,
3900
+ message: "file_path is required for all snippet file actions",
3901
+ path: ["file_path"],
3902
+ });
3903
+ }
3904
+ if ((data.action === "create" || data.action === "update") && data.content === undefined) {
3905
+ ctx.addIssue({
3906
+ code: z.ZodIssueCode.custom,
3907
+ message: `content is required for action: '${data.action}'`,
3908
+ path: ["content"],
3909
+ });
3910
+ }
3911
+ if (data.action === "move" && data.previous_path === undefined) {
3912
+ ctx.addIssue({
3913
+ code: z.ZodIssueCode.custom,
3914
+ message: "previous_path is required for action: 'move'",
3915
+ path: ["previous_path"],
3916
+ });
3917
+ }
3918
+ })
3919
+ .describe("Multi-file snippet update action. Mirrors GitLab's PUT /snippets/:id files[] payload.");
3920
+ export const CreateSnippetSchema = z
3921
+ .object({
3922
+ project_id: z
3923
+ .coerce.string()
3924
+ .optional()
3925
+ .describe("Project ID or URL-encoded path. Omit for a personal snippet, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
3926
+ title: z.string().describe("Snippet title"),
3927
+ file_name: z
3928
+ .string()
3929
+ .optional()
3930
+ .describe("File name for a single-file snippet (e.g., 'script.py'). Use together with content. Mutually exclusive with files[]."),
3931
+ content: z
3932
+ .string()
3933
+ .optional()
3934
+ .describe("File content for a single-file snippet. Use together with file_name."),
3935
+ files: z
3936
+ .array(SnippetFileSchema)
3937
+ .optional()
3938
+ .describe("Array of files for a multi-file snippet. Each item needs file_path and content. Mutually exclusive with file_name/content."),
3939
+ description: z.string().optional().describe("Optional snippet description"),
3940
+ visibility: z
3941
+ .enum(["private", "internal", "public"])
3942
+ .optional()
3943
+ .describe("Snippet visibility (default: private)"),
3944
+ })
3945
+ .refine(data => (data.files !== undefined && data.files.length > 0) ||
3946
+ (data.file_name !== undefined && data.content !== undefined), {
3947
+ message: "Provide either files[] (multi-file) or both file_name and content (single-file)",
3948
+ path: ["files"],
3949
+ })
3950
+ .refine(data => !(data.files !== undefined &&
3951
+ (data.file_name !== undefined || data.content !== undefined)), {
3952
+ message: "Cannot mix files[] with file_name/content — pick one shape",
3953
+ path: ["files"],
3954
+ });
3955
+ export const UpdateSnippetSchema = z
3956
+ .object({
3957
+ project_id: z
3958
+ .coerce.string()
3959
+ .optional()
3960
+ .describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
3961
+ snippet_id: z.coerce.number().describe("The snippet ID to update"),
3962
+ title: z.string().optional().describe("New title"),
3963
+ file_name: z
3964
+ .string()
3965
+ .optional()
3966
+ .describe("File name to update. Provide together with content to replace a single-file snippet's contents."),
3967
+ content: z.string().optional().describe("New file content (requires file_name)"),
3968
+ files: z
3969
+ .array(SnippetFileUpdateActionSchema)
3970
+ .min(1, "files[] must be non-empty when provided")
3971
+ .optional()
3972
+ .describe("Multi-file update actions. Each item has 'action' (create/update/delete/move) plus the relevant path/content fields. Use this for renames (action: 'move' with previous_path), deletions, and additions. Mutually exclusive with file_name/content."),
3973
+ description: z.string().optional().describe("New description"),
3974
+ visibility: z
3975
+ .enum(["private", "internal", "public"])
3976
+ .optional()
3977
+ .describe("New visibility"),
3978
+ })
3979
+ .refine(data => data.title !== undefined ||
3980
+ data.content !== undefined ||
3981
+ data.file_name !== undefined ||
3982
+ data.files !== undefined ||
3983
+ data.description !== undefined ||
3984
+ data.visibility !== undefined, { message: "At least one field must be provided to update" })
3985
+ .refine(data => !(data.files !== undefined &&
3986
+ (data.file_name !== undefined || data.content !== undefined)), {
3987
+ message: "Cannot mix files[] with file_name/content — pick one shape",
3988
+ path: ["files"],
3989
+ })
3990
+ .refine(data => !(data.content !== undefined && data.file_name === undefined), {
3991
+ message: "content requires file_name",
3992
+ path: ["content"],
3993
+ })
3994
+ .refine(data => !(data.file_name !== undefined && data.content === undefined), {
3995
+ message: "file_name requires content — renaming a file alone is not supported (the modern GitLab snippet API requires a files[] action with previous_path)",
3996
+ path: ["file_name"],
3997
+ });
3998
+ export const DeleteSnippetSchema = z.object({
3999
+ project_id: z
4000
+ .coerce.string()
4001
+ .optional()
4002
+ .describe("Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set."),
4003
+ snippet_id: z.coerce.number().describe("The snippet ID to delete"),
4004
+ });
4005
+ export const GitLabSnippetAuthorSchema = z
4006
+ .object({
4007
+ id: z.number().optional(),
4008
+ username: z.string().optional(),
4009
+ name: z.string().optional(),
4010
+ state: z.string().optional(),
4011
+ avatar_url: z.string().nullable().optional(),
4012
+ web_url: z.string().optional(),
4013
+ })
4014
+ .passthrough();
4015
+ export const GitLabSnippetFileSchema = z
4016
+ .object({
4017
+ path: z.string(),
4018
+ raw_url: z.string().optional(),
4019
+ })
4020
+ .passthrough();
4021
+ export const GitLabSnippetSchema = z
4022
+ .object({
4023
+ id: z.number(),
4024
+ title: z.string(),
4025
+ description: z.string().nullable().optional(),
4026
+ visibility: z.string().optional(),
4027
+ author: GitLabSnippetAuthorSchema.optional(),
4028
+ updated_at: z.string().optional(),
4029
+ created_at: z.string().optional(),
4030
+ web_url: z.string().optional(),
4031
+ raw_url: z.string().optional(),
4032
+ file_name: z.string().nullable().optional(),
4033
+ files: z.array(GitLabSnippetFileSchema).optional(),
4034
+ project_id: z.number().nullable().optional(),
4035
+ })
4036
+ .passthrough();
3850
4037
  export const GitLabTagSignatureSchema = z.object({
3851
4038
  signature_type: z.literal("X509"),
3852
4039
  verification_status: z.string(),
@@ -123,6 +123,10 @@ const GROUP_META = {
123
123
  title: "GitLab Orbit",
124
124
  blurb: "Query the Orbit SDLC knowledge graph (Beta; Premium/Ultimate). Graph queries consume GitLab credits; schema, status, and tool listing are free.",
125
125
  },
126
+ snippets: {
127
+ title: "Snippets",
128
+ blurb: "Snippet CRUD — list, get (with optional file content), create, update, and delete personal or project snippets.",
129
+ },
126
130
  };
127
131
  const GROUP_ORDER = [
128
132
  "projects",
@@ -146,6 +150,7 @@ const GROUP_ORDER = [
146
150
  "dependency_proxy",
147
151
  "vulnerabilities",
148
152
  "orbit",
153
+ "snippets",
149
154
  ];
150
155
  // Authoritative classification — uses the `readOnlyTools` set from
151
156
  // tools/registry.ts. That set is what the server itself consults to decide
@@ -50,6 +50,7 @@ export const GROUP_ORDER = [
50
50
  "dependency_proxy",
51
51
  "vulnerabilities",
52
52
  "orbit",
53
+ "snippets",
53
54
  ];
54
55
  export const GROUP_TITLE = {
55
56
  merge_requests: "Merge Requests",
@@ -73,6 +74,7 @@ export const GROUP_TITLE = {
73
74
  dependency_proxy: "Dependency Proxy",
74
75
  vulnerabilities: "Vulnerabilities",
75
76
  orbit: "GitLab Orbit",
77
+ snippets: "Snippets",
76
78
  meta: "Meta & GraphQL",
77
79
  };
78
80
  export const CANARY_INVOKED = [
@@ -5,6 +5,22 @@ import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server
5
5
  import { CustomHeaderClient } from "./clients/custom-header-client.js";
6
6
  const MOCK_TOKEN = "glpat-dynamic-scope-token";
7
7
  const SCOPE_HEADER = "x-gitlab-allowed-project-ids";
8
+ function parseToolJson(result) {
9
+ assert.ok(result.isError !== true, "tool should succeed");
10
+ const first = result.content[0];
11
+ assert.ok(first !== undefined && first.type === "text");
12
+ return JSON.parse(first.text);
13
+ }
14
+ function snippetTitleFrom(value) {
15
+ if (value === null || typeof value !== "object" || !("title" in value)) {
16
+ assert.fail("expected a snippet object with title");
17
+ }
18
+ const title = value.title;
19
+ if (typeof title !== "string") {
20
+ assert.fail("expected snippet title to be a string");
21
+ }
22
+ return title;
23
+ }
8
24
  async function connectClient(mcpUrl, scope) {
9
25
  const headers = { authorization: `Bearer ${MOCK_TOKEN}` };
10
26
  if (scope !== undefined) {
@@ -323,6 +339,150 @@ describe("Dynamic project scope", { concurrency: 1 }, () => {
323
339
  }
324
340
  });
325
341
  });
342
+ describe("with session-scoped snippet tools", () => {
343
+ let mockGitLab;
344
+ const servers = [];
345
+ let mcpUrl;
346
+ const snippetHits = [];
347
+ before(async () => {
348
+ const mockPort = await findMockServerPort();
349
+ mockGitLab = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
350
+ mockGitLab.addMockHandler("get", "/snippets", (_req, res) => {
351
+ snippetHits.push("personal:list");
352
+ res.json([{ id: 99, title: "personal snippet of the token owner" }]);
353
+ });
354
+ mockGitLab.addMockHandler("get", "/projects/5/snippets", (_req, res) => {
355
+ snippetHits.push("project:list");
356
+ res.json([{ id: 42, title: "project snippet" }]);
357
+ });
358
+ mockGitLab.addMockHandler("get", "/projects/5/snippets/42", (_req, res) => {
359
+ snippetHits.push("project:get");
360
+ res.json({ id: 42, title: "project snippet" });
361
+ });
362
+ mockGitLab.addMockHandler("get", "/snippets/42", (_req, res) => {
363
+ snippetHits.push("personal:get");
364
+ res.json({ id: 42, title: "personal snippet of the token owner" });
365
+ });
366
+ mockGitLab.addMockHandler("delete", "/snippets/99", (_req, res) => {
367
+ snippetHits.push("personal:delete");
368
+ res.status(204).end();
369
+ });
370
+ mockGitLab.addMockHandler("delete", "/projects/5/snippets/42", (_req, res) => {
371
+ snippetHits.push("project:delete");
372
+ res.status(204).end();
373
+ });
374
+ mockGitLab.addMockHandler("post", "/snippets", (_req, res) => {
375
+ snippetHits.push("personal:create");
376
+ res.json({ id: 99, title: "personal snippet of the token owner" });
377
+ });
378
+ mockGitLab.addMockHandler("post", "/projects/5/snippets", (_req, res) => {
379
+ snippetHits.push("project:create");
380
+ res.json({ id: 42, title: "project snippet" });
381
+ });
382
+ mockGitLab.addMockHandler("put", "/snippets/42", (_req, res) => {
383
+ snippetHits.push("personal:update");
384
+ res.json({ id: 42, title: "updated personal snippet" });
385
+ });
386
+ mockGitLab.addMockHandler("put", "/projects/5/snippets/42", (_req, res) => {
387
+ snippetHits.push("project:update");
388
+ res.json({ id: 42, title: "updated project snippet" });
389
+ });
390
+ await mockGitLab.start();
391
+ const mcpPort = await findAvailablePort(3200);
392
+ const server = await launchServer({
393
+ mode: TransportMode.STREAMABLE_HTTP,
394
+ port: mcpPort,
395
+ timeout: 5000,
396
+ env: {
397
+ STREAMABLE_HTTP: "true",
398
+ REMOTE_AUTHORIZATION: "true",
399
+ ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
400
+ GITLAB_TOOLSETS: "snippets",
401
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
402
+ },
403
+ });
404
+ servers.push(server);
405
+ mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
406
+ });
407
+ after(async () => {
408
+ cleanupServers(servers);
409
+ await mockGitLab?.stop();
410
+ });
411
+ test("list_snippets without project_id stays within the session scope", async () => {
412
+ snippetHits.length = 0;
413
+ const client = await connectClient(mcpUrl, "5");
414
+ try {
415
+ const result = await client.callTool("list_snippets", {});
416
+ assert.ok(result.content, "Should have content");
417
+ const content = result.content[0];
418
+ assert.ok("text" in content, "Content should have text");
419
+ const snippets = JSON.parse(content.text);
420
+ assert.strictEqual(snippets[0].title, "project snippet");
421
+ }
422
+ finally {
423
+ await client.disconnect();
424
+ }
425
+ assert.deepStrictEqual(snippetHits, ["project:list"], "must route to the scoped project, not the personal /snippets endpoint");
426
+ });
427
+ test("delete_snippet without project_id stays within the session scope", async () => {
428
+ snippetHits.length = 0;
429
+ const client = await connectClient(mcpUrl, "5");
430
+ try {
431
+ await client.callTool("delete_snippet", { snippet_id: 42 });
432
+ }
433
+ finally {
434
+ await client.disconnect();
435
+ }
436
+ assert.deepStrictEqual(snippetHits, ["project:delete"], "must route to the scoped project, not the personal /snippets endpoint");
437
+ });
438
+ test("get_snippet without project_id stays within the session scope", async () => {
439
+ snippetHits.length = 0;
440
+ const client = await connectClient(mcpUrl, "5");
441
+ try {
442
+ const result = await client.callTool("get_snippet", { snippet_id: 42 });
443
+ assert.ok(result.content, "Should have content");
444
+ const content = result.content[0];
445
+ assert.ok("text" in content, "Content should have text");
446
+ const snippet = JSON.parse(content.text);
447
+ assert.strictEqual(snippet.title, "project snippet");
448
+ }
449
+ finally {
450
+ await client.disconnect();
451
+ }
452
+ assert.deepStrictEqual(snippetHits, ["project:get"], "must route to the scoped project, not the personal /snippets endpoint");
453
+ });
454
+ test("create_snippet without project_id stays within the session scope", async () => {
455
+ snippetHits.length = 0;
456
+ const client = await connectClient(mcpUrl, "5");
457
+ try {
458
+ const result = await client.callTool("create_snippet", {
459
+ title: "scoped create",
460
+ file_name: "note.md",
461
+ content: "hi",
462
+ });
463
+ assert.strictEqual(snippetTitleFrom(parseToolJson(result)), "project snippet");
464
+ }
465
+ finally {
466
+ await client.disconnect();
467
+ }
468
+ assert.deepStrictEqual(snippetHits, ["project:create"], "must route to the scoped project, not the personal /snippets endpoint");
469
+ });
470
+ test("update_snippet without project_id stays within the session scope", async () => {
471
+ snippetHits.length = 0;
472
+ const client = await connectClient(mcpUrl, "5");
473
+ try {
474
+ const result = await client.callTool("update_snippet", {
475
+ snippet_id: 42,
476
+ title: "scoped update",
477
+ });
478
+ assert.strictEqual(snippetTitleFrom(parseToolJson(result)), "updated project snippet");
479
+ }
480
+ finally {
481
+ await client.disconnect();
482
+ }
483
+ assert.deepStrictEqual(snippetHits, ["project:update"], "must route to the scoped project, not the personal /snippets endpoint");
484
+ });
485
+ });
326
486
  describe("with ENABLE_DYNAMIC_PROJECT_SCOPE disabled", () => {
327
487
  let mockGitLab;
328
488
  const servers = [];