diff --git a/README.ko.md b/README.ko.md index 3a0769187..7b912a074 100644 --- a/README.ko.md +++ b/README.ko.md @@ -17,7 +17,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며 ### 왜 이 GitLab MCP를 사용하나요? -- **232개 도구 + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화 +- **237개 도구 + `discover_tools`** — 작은 toolset으로 시작하고, 런타임에 카테고리 활성화 - **MR 2단계 리뷰** — `list_merge_request_changed_files` → 배치 `get_merge_request_file_diff` - **Agent Skill 내장** — `skills/gitlab-mcp/` 워크플로우 가이드 - **유연한 인증** — Personal Access Token, 로컬 OAuth2 브라우저 플로우, MCP OAuth 프록시, 요청별 원격 인증 @@ -30,7 +30,7 @@ PAT, OAuth, 읽기 전용 모드, 동적 API URL, 원격 인증을 지원하며 | | @zereight/mcp-gitlab | GitLab MCP A (커뮤니티 CQRS형) | |---|----------------------|--------------------------------| | **적합한 경우** | AI 에이전트 워크플로우 | 엔터프라이즈 멀티 인스턴스 / 그룹형 도구 | -| **도구 모델** | ~232개 세분화 도구 + `discover_tools` | ~50–60개 `browse_*` / `manage_*` 그룹 도구 | +| **도구 모델** | ~237개 세분화 도구 + `discover_tools` | ~50–60개 `browse_*` / `manage_*` 그룹 도구 | | **MR 리뷰** | 2단계 배치 diff | 서버마다 다름 | | **Node.js** | >=18.17 | 보통 >=24 | | **라이선스** | MIT | 서버마다 다름 | diff --git a/README.md b/README.md index 632c16951..6af0a9b11 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization ### Why use this GitLab MCP? -- **232 tools + `discover_tools`** — start with a small toolset; activate more at runtime without CQRS-style grouping +- **237 tools + `discover_tools`** — start with a small toolset; activate more at runtime without CQRS-style grouping - **MR 2-step review** — `list_merge_request_changed_files` → batched `get_merge_request_file_diff` - **Agent Skill built in** — workflow guidance in `skills/gitlab-mcp/` - **Flexible auth** — Personal Access Token, local OAuth2 browser flow, MCP OAuth proxy, and per-request remote authorization @@ -35,7 +35,7 @@ Supports PAT, OAuth, read-only mode, dynamic API URLs, and remote authorization | | @zereight/mcp-gitlab | GitLab MCP A (community CQRS-style) | |---|----------------------|-------------------------------------| | **Best for** | AI agent workflows | Enterprise multi-instance / grouped tools | -| **Tool model** | ~232 granular tools + `discover_tools` | ~50–60 grouped `browse_*` / `manage_*` tools | +| **Tool model** | ~237 granular tools + `discover_tools` | ~50–60 grouped `browse_*` / `manage_*` tools | | **MR review** | 2-step batched diff | Varies | | **Node.js** | >=18.17 | Often >=24 | | **License** | MIT | Varies | @@ -815,7 +815,12 @@ Register the skill directory in your AI client to get optimal tool usage guidanc 230. `get_vulnerability` - Get full details of a specific vulnerability 231. `dismiss_vulnerability` - Dismiss a vulnerability with a reason (acceptable_risk, false_positive, used_in_tests, mitigating_control, not_applicable) and optional comment 232. `confirm_vulnerability` - Confirm a vulnerability as a real finding requiring remediation -233. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities. Already-active categories are listed in the response. +233. `list_snippets` - List snippets — project snippets when project_id is given, otherwise personal snippets +234. `get_snippet` - Get a snippet's metadata. Set include_content=true to also fetch the raw file content. +235. `create_snippet` - Create a snippet — project-scoped when project_id is given, otherwise a personal snippet. Supports single-file (file_name + content) or multi-file (files[]). +236. `update_snippet` - Update an existing snippet (provide at least one field to change). For multi-file edits — renames, deletions, additions — pass files[] with action (create/update/delete/move) and previous_path. The file_name + content shortcut still works for single-file content replacement. +237. `delete_snippet` - Delete a snippet +238. `discover_tools` - Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities, snippets. Already-active categories are listed in the response. diff --git a/README.zh-CN.md b/README.zh-CN.md index 680e124ec..a12138a74 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -17,7 +17,7 @@ ### 为什么使用这个 GitLab MCP? -- **232 个工具 + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别 +- **237 个工具 + `discover_tools`** — 从小型 toolset 开始,运行时按需激活类别 - **MR 两步审查** — `list_merge_request_changed_files` → 批量 `get_merge_request_file_diff` - **内置 Agent Skill** — `skills/gitlab-mcp/` 工作流指南 - **认证灵活** — Personal Access Token、本地 OAuth2 浏览器流程、MCP OAuth 代理、按请求远程授权 @@ -30,7 +30,7 @@ | | @zereight/mcp-gitlab | GitLab MCP A(社区 CQRS 型) | |---|----------------------|------------------------------| | **更适合** | AI 代理工作流 | 企业多实例 / 分组工具 | -| **工具模型** | ~232 个细粒度工具 + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 | +| **工具模型** | ~237 个细粒度工具 + `discover_tools` | ~50–60 个 `browse_*` / `manage_*` 分组工具 | | **MR 审查** | 两步批量 diff | 因服务器而异 | | **Node.js** | >=18.17 | 通常 >=24 | | **许可证** | MIT | 因服务器而异 | diff --git a/docs/tools/index.md b/docs/tools/index.md index 422602a2b..d48c37ea9 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -17,7 +17,7 @@ directly from `TOOLSET_DEFINITIONS` in | Status | Groups | |---|---| | **Default** — always exposed | [Projects & Namespaces](projects.md), [Projects & Files](repositories.md), [Branches & Commits](branches.md), [Groups](groups.md), [Merge Requests](merge-requests.md), [Issues](issues.md), [Labels](labels.md), [CI Lint](ci.md), [Users & Events](users.md) | -| **Opt-in** — must be enabled | [Work Items](workitems.md), [Pipelines, Jobs & Deployments](pipelines.md) (also `USE_PIPELINE=true`), [Milestones](milestones.md) (also `USE_MILESTONE=true`), [Wiki](wiki.md) (also `USE_GITLAB_WIKI=true`), [Releases](releases.md), [Tags](tags.md), [Variables](variables.md), [Webhooks](webhooks.md), [Search](search.md), [Dependency Proxy](dependency-proxy.md), [Vulnerabilities](vulnerabilities.md), [Meta & GraphQL](meta.md) | +| **Opt-in** — must be enabled | [Work Items](workitems.md), [Pipelines, Jobs & Deployments](pipelines.md) (also `USE_PIPELINE=true`), [Milestones](milestones.md) (also `USE_MILESTONE=true`), [Wiki](wiki.md) (also `USE_GITLAB_WIKI=true`), [Releases](releases.md), [Tags](tags.md), [Variables](variables.md), [Webhooks](webhooks.md), [Search](search.md), [Dependency Proxy](dependency-proxy.md), [Vulnerabilities](vulnerabilities.md), [Snippets](snippets.md), [Meta & GraphQL](meta.md) | **How to enable opt-in groups** (any one is sufficient): @@ -467,6 +467,20 @@ AI-assisted vulnerability triage — list findings, inspect details, dismiss wit | [`dismiss_vulnerability`](vulnerabilities.md#dismiss_vulnerability) | Dismiss a vulnerability with a reason (acceptable_risk, false_positive, used_in_tests, mitigating_control, not_applicable) and optional comment. Use this for the specific operation described; choose a sibling tool when you need a different resource or lifecycle action. It changes remote GitLab state and requires the necessary project or group permission; GitLab returns validation, conflict, permission, or rate-limit errors instead of silently applying an invalid request. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. | ✏️ | | [`confirm_vulnerability`](vulnerabilities.md#confirm_vulnerability) | Confirm a vulnerability as a real finding requiring remediation. Use this for the specific operation described; choose a sibling tool when you need a different resource or lifecycle action. It changes remote GitLab state and requires the necessary project or group permission; GitLab returns validation, conflict, permission, or rate-limit errors instead of silently applying an invalid request. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. | ✏️ | +### [Snippets](snippets.md) + +Snippet CRUD — list, get (with optional file content), create, update, and delete personal or project snippets. *(5 tools)* + +> Opt-in. Enable via `GITLAB_TOOLSETS=snippets` (or `GITLAB_TOOLSETS=all`), list individual tools in `GITLAB_TOOLS=`, or activate at runtime with the `discover_tools` MCP tool. + +| Tool | What it does | R/W | +|---|---|:-:| +| [`list_snippets`](snippets.md#list_snippets) | List snippets — project snippets when project_id is given, otherwise personal snippets. Use this for a collection of resources; choose the corresponding get tool when you already know the single resource to inspect. It is read-only and does not mutate GitLab data; missing resources, invalid identifiers, insufficient permission, and rate limits are returned as errors. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. | 📖 | +| [`get_snippet`](snippets.md#get_snippet) | Get a snippet's metadata. Set include_content=true to also fetch the raw file content. Use this for a known resource or result; choose the corresponding list or search tool when you need to discover multiple resources. It is read-only and does not mutate GitLab data; missing resources, invalid identifiers, insufficient permission, and rate limits are returned as errors. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. | 📖 | +| [`create_snippet`](snippets.md#create_snippet) | Create a snippet — project-scoped when project_id is given, otherwise a personal snippet. Supports single-file (file_name + content) or multi-file (files[]). Use this for a new resource or action; choose the corresponding update or edit tool when the resource already exists. It changes remote GitLab state and requires the necessary project or group permission; GitLab returns validation, conflict, permission, or rate-limit errors instead of silently applying an invalid request. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. | ✏️ | +| [`update_snippet`](snippets.md#update_snippet) | Update an existing snippet (provide at least one field to change). For multi-file edits — renames, deletions, additions — pass files[] with action (create/update/delete/move) and previous_path. The file_name + content shortcut still works for single-file content replacement. | ✏️ | +| [`delete_snippet`](snippets.md#delete_snippet) | Delete a snippet. Use this only after verifying the target; choose a get or list tool first when you need to inspect state without changing it. It changes or removes remote GitLab data and may be irreversible; it requires the necessary project or group permission and returns validation, conflict, permission, or rate-limit errors. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. | ✏️ | + ### [Meta & GraphQL](meta.md) Server diagnostics, tool discovery, and the GraphQL escape hatch. *(2 tools)* @@ -476,7 +490,7 @@ Server diagnostics, tool discovery, and the GraphQL escape hatch. *(2 tools)* | Tool | What it does | R/W | |---|---|:-:| | [`execute_graphql`](meta.md#execute_graphql) | Execute a GitLab GraphQL query. Use this only when a supported GitLab REST tool does not cover the requested operation; prefer a typed tool when one exists. The query is sent directly to GitLab and can include mutations when permission allows, so callers must treat it as potentially state-changing and handle GraphQL errors in the returned response. | 📖 | -| [`discover_tools`](meta.md#discover_tools) | Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities. Already-active categories are listed in the response. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data. | 📖 | +| [`discover_tools`](meta.md#discover_tools) | Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities. Already-active categories are listed in the response. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data. | 📖 | --- diff --git a/docs/tools/meta.md b/docs/tools/meta.md index ee55bd940..f217802f3 100644 --- a/docs/tools/meta.md +++ b/docs/tools/meta.md @@ -29,7 +29,7 @@ Execute a GitLab GraphQL query. Use this only when a supported GitLab REST tool *📖 Read-only* -Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities. Already-active categories are listed in the response. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data. +Discover and activate additional tool categories for this session. Available categories: merge_requests, issues, repositories, branches, projects, labels, ci, groups, pipelines, milestones, wiki, releases, tags, snippets, users, workitems, webhooks, search, variables, dependency_proxy, vulnerabilities. Already-active categories are listed in the response. Use this when a needed opt-in category is not currently exposed; omit `category` to inspect available categories, then call it with a category to activate that group for the current session. It changes only the session's tool registry, returns the active-tool summary, and does not change GitLab data. **Parameters** diff --git a/docs/tools/snippets.md b/docs/tools/snippets.md new file mode 100644 index 000000000..a06922396 --- /dev/null +++ b/docs/tools/snippets.md @@ -0,0 +1,95 @@ +# Snippets + +Snippet CRUD — list, get (with optional file content), create, update, and delete personal or project snippets. + +!!! note "Feature toggle" + Opt-in. Enable via `GITLAB_TOOLSETS=snippets` (or `GITLAB_TOOLSETS=all`), list individual tools in `GITLAB_TOOLS=`, or activate at runtime with the `discover_tools` MCP tool. + +## Tools in this group + +- [`list_snippets`](#list_snippets) — 📖 Read-only +- [`get_snippet`](#get_snippet) — 📖 Read-only +- [`create_snippet`](#create_snippet) — ✏️ Writes +- [`update_snippet`](#update_snippet) — ✏️ Writes +- [`delete_snippet`](#delete_snippet) — ✏️ Writes + +--- + +### `list_snippets` + +*📖 Read-only* + +List snippets — project snippets when project_id is given, otherwise personal snippets. Use this for a collection of resources; choose the corresponding get tool when you already know the single resource to inspect. It is read-only and does not mutate GitLab data; missing resources, invalid identifiers, insufficient permission, and rate limits are returned as errors. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. + +**Parameters** + +| Parameter | Type | Required | Description | +|---|---|:-:|---| +| `project_id` | string | | Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set. | +| `page` | number | | Page number for pagination (default: 1) | +| `per_page` | number | | Number of items per page (max: 100, default: 20) | + +### `get_snippet` + +*📖 Read-only* + +Get a snippet's metadata. Set include_content=true to also fetch the raw file content. Use this for a known resource or result; choose the corresponding list or search tool when you need to discover multiple resources. It is read-only and does not mutate GitLab data; missing resources, invalid identifiers, insufficient permission, and rate limits are returned as errors. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. + +**Parameters** + +| Parameter | Type | Required | Description | +|---|---|:-:|---| +| `project_id` | string | | Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set. | +| `snippet_id` | number | ✓ | The snippet ID | +| `include_content` | boolean | | Whether to fetch the raw file content (default: false) | +| `ref` | string | | Branch, tag, or commit to fetch content from. Inferred from the snippet's raw_url when omitted. | + +### `create_snippet` + +*✏️ Writes* + +Create a snippet — project-scoped when project_id is given, otherwise a personal snippet. Supports single-file (file_name + content) or multi-file (files[]). Use this for a new resource or action; choose the corresponding update or edit tool when the resource already exists. It changes remote GitLab state and requires the necessary project or group permission; GitLab returns validation, conflict, permission, or rate-limit errors instead of silently applying an invalid request. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. + +**Parameters** + +| Parameter | Type | Required | Description | +|---|---|:-:|---| +| `project_id` | string | | 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. | +| `title` | string | ✓ | Snippet title | +| `file_name` | string | | File name for a single-file snippet (e.g., 'script.py'). Use together with content. Mutually exclusive with files[]. | +| `content` | string | | File content for a single-file snippet. Use together with file_name. | +| `files` | array | | Array of files for a multi-file snippet. Each item needs file_path and content. Mutually exclusive with file_name/content. | +| `description` | string | | Optional snippet description | +| `visibility` | enum (`private` \| `internal` \| `public`) | | Snippet visibility (default: private) | + +### `update_snippet` + +*✏️ Writes* + +Update an existing snippet (provide at least one field to change). For multi-file edits — renames, deletions, additions — pass files[] with action (create/update/delete/move) and previous_path. The file_name + content shortcut still works for single-file content replacement. + +**Parameters** + +| Parameter | Type | Required | Description | +|---|---|:-:|---| +| `project_id` | string | | Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set. | +| `snippet_id` | number | ✓ | The snippet ID to update | +| `title` | string | | New title | +| `file_name` | string | | File name to update. Provide together with content to replace a single-file snippet's contents. | +| `content` | string | | New file content (requires file_name) | +| `files` | array | | 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. | +| `description` | string | | New description | +| `visibility` | enum (`private` \| `internal` \| `public`) | | New visibility | + +### `delete_snippet` + +*✏️ Writes* + +Delete a snippet. Use this only after verifying the target; choose a get or list tool first when you need to inspect state without changing it. It changes or removes remote GitLab data and may be irreversible; it requires the necessary project or group permission and returns validation, conflict, permission, or rate-limit errors. When `project_id` or `group_id` is accepted, provide the numeric ID or complete URL-encoded path described by the schema; use required identifiers and pagination fields exactly as documented. + +**Parameters** + +| Parameter | Type | Required | Description | +|---|---|:-:|---| +| `project_id` | string | | Project ID or URL-encoded path. Omit for personal snippets, or the configured project when GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS is set. | +| `snippet_id` | number | ✓ | The snippet ID to delete | diff --git a/index.ts b/index.ts index c8c776caa..0edb76ae1 100644 --- a/index.ts +++ b/index.ts @@ -596,6 +596,13 @@ import { GitLabTagSignatureSchema, type GitLabTag, type GitLabTagSignature, + ListSnippetsSchema, + GetSnippetSchema, + CreateSnippetSchema, + UpdateSnippetSchema, + DeleteSnippetSchema, + GitLabSnippetSchema, + type GitLabSnippet, GetMergeRequestNotesSchema, GetMergeRequestNoteSchema, DeleteMergeRequestDiscussionNoteSchema, @@ -10765,6 +10772,201 @@ async function executeGitLabGraphQL(query: string, variables: Record 0 || !!GITLAB_PROJECT_ID; + if (projectId || scopeActive) { + const decoded = projectId ? decodeURIComponent(projectId) : ""; + const effectiveProjectId = getEffectiveProjectId(decoded); + return `${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/snippets`; + } + return `${getEffectiveApiUrl()}/snippets`; +} + +/** + * List snippets — project snippets if projectId is given, otherwise personal snippets. + */ +async function listSnippets( + projectId: string | undefined, + options: Omit, "project_id"> = {} +): Promise { + const url = new URL(getSnippetsEndpoint(projectId)); + + Object.entries(options).forEach(([key, value]) => { + if (value !== undefined) { + url.searchParams.append(key, String(value)); + } + }); + + const response = await fetch(url.toString(), { + ...getFetchConfig(), + }); + + await handleGitLabError(response); + + const data = await response.json(); + return GitLabSnippetSchema.array().parse(data); +} + +/** + * Get a snippet's metadata. Use getSnippetRawContent to fetch the raw file content. + */ +async function getSnippet( + projectId: string | undefined, + snippetId: number +): Promise { + const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}`, { + ...getFetchConfig(), + }); + + await handleGitLabError(response); + + const data = await response.json(); + return GitLabSnippetSchema.parse(data); +} + +/** + * Get the raw content of a single-file snippet via the `/raw` endpoint. + * For multi-file snippets, use getSnippetFileRawContent instead. + */ +async function getSnippetRawContent( + projectId: string | undefined, + snippetId: number +): Promise { + const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}/raw`, { + ...getFetchConfig(), + }); + + await handleGitLabError(response); + + return await response.text(); +} + +/** + * Extract the ref (branch/tag/commit) from a snippet file's raw_url. + * Anchors on /snippets/{id}/raw/ so branch names or file paths containing + * the word "raw" don't produce a false match. + */ +function extractSnippetRef(rawUrl: string, snippetId: number, filePath: string): string { + const rawMarker = `/snippets/${snippetId}/raw/`; + const decoded = decodeURIComponent(new URL(rawUrl).pathname); + const markerIdx = decoded.indexOf(rawMarker); + if (markerIdx === -1) { + throw new Error(`Cannot extract ref from snippet file raw_url: ${rawUrl}`); + } + const afterRaw = decoded.slice(markerIdx + rawMarker.length); // "{ref}/{filePath}" + const fileStart = afterRaw.lastIndexOf("/" + filePath); + if (fileStart === -1) { + throw new Error(`Cannot locate file path "${filePath}" in snippet file raw_url: ${rawUrl}`); + } + return afterRaw.slice(0, fileStart); +} + +/** + * Fetch the raw content of one file inside a multi-file snippet. + * Accepts an explicit ref (branch/tag/commit) — callers resolve it via + * extractSnippetRef or a user-supplied parameter before calling this. + */ +async function getSnippetFileRawContent( + projectId: string | undefined, + snippetId: number, + ref: string, + filePath: string +): Promise { + const encodedRef = encodeURIComponent(ref); + const encodedPath = encodeURIComponent(filePath); + const url = `${getSnippetsEndpoint(projectId)}/${snippetId}/files/${encodedRef}/${encodedPath}/raw`; + const response = await fetch(url, { ...getFetchConfig() }); + await handleGitLabError(response); + return await response.text(); +} + +/** + * Create a snippet — project-scoped if projectId is given, otherwise personal. + */ +async function createSnippet( + projectId: string | undefined, + options: Omit, "project_id"> +): Promise { + const { title, file_name, content, files, description, visibility } = options; + const filesPayload = + files && files.length > 0 + ? files + : [{ file_path: file_name as string, content: content as string }]; + const body: Record = { + title, + visibility: visibility ?? "private", + files: filesPayload, + }; + if (description !== undefined) { + body.description = description; + } + + const response = await fetch(getSnippetsEndpoint(projectId), { + ...getFetchConfig(), + method: "POST", + body: JSON.stringify(body), + }); + + await handleGitLabError(response); + + const data = await response.json(); + return GitLabSnippetSchema.parse(data); +} + +/** + * Update an existing snippet — project-scoped if projectId is given, otherwise personal. + */ +async function updateSnippet( + projectId: string | undefined, + options: Omit, "project_id"> +): Promise { + const { snippet_id, title, file_name, content, files, description, visibility } = options; + const body: Record = {}; + if (title !== undefined) body.title = title; + if (description !== undefined) body.description = description; + if (visibility !== undefined) body.visibility = visibility; + + if (files !== undefined) { + body.files = files; + } else if (file_name !== undefined && content !== undefined) { + body.files = [{ action: "update", file_path: file_name, content }]; + } + + const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippet_id}`, { + ...getFetchConfig(), + method: "PUT", + body: JSON.stringify(body), + }); + + await handleGitLabError(response); + + const data = await response.json(); + return GitLabSnippetSchema.parse(data); +} + +/** + * Delete a snippet — project-scoped if projectId is given, otherwise personal. + */ +async function deleteSnippet( + projectId: string | undefined, + snippetId: number +): Promise { + const response = await fetch(`${getSnippetsEndpoint(projectId)}/${snippetId}`, { + ...getFetchConfig(), + method: "DELETE", + }); + + await handleGitLabError(response); +} + // Request handlers are now registered inside createServer() factory function // to ensure each transport connection gets its own Server instance (GHSA-345p-7cg4-v4c7). @@ -13916,6 +14118,97 @@ async function handleToolCall(params: any) { }; } + case "list_snippets": { + const args = ListSnippetsSchema.parse(params.arguments); + const { project_id, ...options } = args; + const snippets = await listSnippets(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(snippets, null, 2) }], + }; + } + + case "get_snippet": { + const args = GetSnippetSchema.parse(params.arguments); + const snippet = await getSnippet(args.project_id, args.snippet_id); + const result: Record = { ...snippet }; + if (args.include_content) { + const files = snippet.files ?? []; + if (files.length > 1) { + const firstFile = files[0]; + let ref: string; + if (args.ref !== undefined) { + ref = args.ref; + } else { + if (!firstFile.raw_url) throw new Error(`Snippet file "${firstFile.path}" has no raw_url`); + ref = extractSnippetRef(firstFile.raw_url, args.snippet_id, firstFile.path); + } + result.files = await Promise.all( + files.map(async f => ({ + ...f, + content: await getSnippetFileRawContent(args.project_id, args.snippet_id, ref, f.path), + })) + ); + } else { + const file = files[0]; + const filePath = file?.path ?? snippet.file_name ?? undefined; + if (args.ref !== undefined && filePath) { + result.content = await getSnippetFileRawContent( + args.project_id, + args.snippet_id, + args.ref, + filePath + ); + } else { + result.content = await getSnippetRawContent( + args.project_id, + args.snippet_id + ); + } + } + } + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + } + + case "create_snippet": { + const args = CreateSnippetSchema.parse(params.arguments); + const { project_id, ...options } = args; + const snippet = await createSnippet(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }], + }; + } + + case "update_snippet": { + const args = UpdateSnippetSchema.parse(params.arguments); + const { project_id, ...options } = args; + const snippet = await updateSnippet(project_id, options); + return { + content: [{ type: "text", text: JSON.stringify(snippet, null, 2) }], + }; + } + + case "delete_snippet": { + const args = DeleteSnippetSchema.parse(params.arguments); + await deleteSnippet(args.project_id, args.snippet_id); + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + status: "success", + message: `Snippet ${args.snippet_id} deleted successfully`, + }, + null, + 2 + ), + }, + ], + }; + } + case "list_webhooks": { const args = ListWebhooksSchema.parse(params.arguments); const webhooks = await listWebhooks(args); diff --git a/schemas.ts b/schemas.ts index 824baa6b4..a8c9f3f70 100644 --- a/schemas.ts +++ b/schemas.ts @@ -4578,6 +4578,249 @@ export const GitLabTagSchema = z.object({ created_at: z.string().nullable(), }); +// --- Snippet schemas --- + +export const ListSnippetsSchema = z + .object({ + project_id: z + .coerce.string() + .optional() + .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." + ), + }) + .merge(PaginationOptionsSchema); + +export const GetSnippetSchema = z.object({ + project_id: z + .coerce.string() + .optional() + .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."), + snippet_id: z.coerce.number().describe("The snippet ID"), + include_content: z + .boolean() + .optional() + .default(false) + .describe("Whether to fetch the raw file content (default: false)"), + ref: z + .string() + .optional() + .describe("Branch, tag, or commit to fetch content from. Inferred from the snippet's raw_url when omitted."), +}); + +export const SnippetFileSchema = z.object({ + file_path: z.string().describe("File path within the snippet (e.g., 'src/script.py')"), + content: z.string().describe("File content"), +}); + +export const SnippetFileUpdateActionSchema = z + .object({ + action: z + .enum(["create", "update", "delete", "move"]) + .describe("Type of action to perform on the file"), + file_path: z + .string() + .optional() + .describe( + "File path of the snippet file. For 'move', this is the new path; for 'create'/'update'/'delete', the target path." + ), + previous_path: z + .string() + .optional() + .describe("Previous path of the snippet file (required for 'move')."), + content: z + .string() + .optional() + .describe("Content of the snippet file (required for 'create' and 'update')."), + }) + .superRefine((data, ctx) => { + if (data.file_path === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "file_path is required for all snippet file actions", + path: ["file_path"], + }); + } + if ((data.action === "create" || data.action === "update") && data.content === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `content is required for action: '${data.action}'`, + path: ["content"], + }); + } + if (data.action === "move" && data.previous_path === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "previous_path is required for action: 'move'", + path: ["previous_path"], + }); + } + }) + .describe( + "Multi-file snippet update action. Mirrors GitLab's PUT /snippets/:id files[] payload." + ); + +export const CreateSnippetSchema = z + .object({ + project_id: z + .coerce.string() + .optional() + .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." + ), + title: z.string().describe("Snippet title"), + file_name: z + .string() + .optional() + .describe( + "File name for a single-file snippet (e.g., 'script.py'). Use together with content. Mutually exclusive with files[]." + ), + content: z + .string() + .optional() + .describe("File content for a single-file snippet. Use together with file_name."), + files: z + .array(SnippetFileSchema) + .optional() + .describe( + "Array of files for a multi-file snippet. Each item needs file_path and content. Mutually exclusive with file_name/content." + ), + description: z.string().optional().describe("Optional snippet description"), + visibility: z + .enum(["private", "internal", "public"]) + .optional() + .describe("Snippet visibility (default: private)"), + }) + .refine( + data => + (data.files !== undefined && data.files.length > 0) || + (data.file_name !== undefined && data.content !== undefined), + { + message: "Provide either files[] (multi-file) or both file_name and content (single-file)", + path: ["files"], + } + ) + .refine( + data => + !( + data.files !== undefined && + (data.file_name !== undefined || data.content !== undefined) + ), + { + message: "Cannot mix files[] with file_name/content — pick one shape", + path: ["files"], + } + ); + +export const UpdateSnippetSchema = z + .object({ + project_id: z + .coerce.string() + .optional() + .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."), + snippet_id: z.coerce.number().describe("The snippet ID to update"), + title: z.string().optional().describe("New title"), + file_name: z + .string() + .optional() + .describe( + "File name to update. Provide together with content to replace a single-file snippet's contents." + ), + content: z.string().optional().describe("New file content (requires file_name)"), + files: z + .array(SnippetFileUpdateActionSchema) + .min(1, "files[] must be non-empty when provided") + .optional() + .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." + ), + description: z.string().optional().describe("New description"), + visibility: z + .enum(["private", "internal", "public"]) + .optional() + .describe("New visibility"), + }) + .refine( + data => + data.title !== undefined || + data.content !== undefined || + data.file_name !== undefined || + data.files !== undefined || + data.description !== undefined || + data.visibility !== undefined, + { message: "At least one field must be provided to update" } + ) + .refine( + data => + !( + data.files !== undefined && + (data.file_name !== undefined || data.content !== undefined) + ), + { + message: "Cannot mix files[] with file_name/content — pick one shape", + path: ["files"], + } + ) + .refine(data => !(data.content !== undefined && data.file_name === undefined), { + message: "content requires file_name", + path: ["content"], + }) + .refine(data => !(data.file_name !== undefined && data.content === undefined), { + message: + "file_name requires content — renaming a file alone is not supported (the modern GitLab snippet API requires a files[] action with previous_path)", + path: ["file_name"], + }); + +export const DeleteSnippetSchema = z.object({ + project_id: z + .coerce.string() + .optional() + .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."), + snippet_id: z.coerce.number().describe("The snippet ID to delete"), +}); + +export const GitLabSnippetAuthorSchema = z + .object({ + id: z.number().optional(), + username: z.string().optional(), + name: z.string().optional(), + state: z.string().optional(), + avatar_url: z.string().nullable().optional(), + web_url: z.string().optional(), + }) + .passthrough(); + +export const GitLabSnippetFileSchema = z + .object({ + path: z.string(), + raw_url: z.string().optional(), + }) + .passthrough(); + +export const GitLabSnippetSchema = z + .object({ + id: z.number(), + title: z.string(), + description: z.string().nullable().optional(), + visibility: z.string().optional(), + author: GitLabSnippetAuthorSchema.optional(), + updated_at: z.string().optional(), + created_at: z.string().optional(), + web_url: z.string().optional(), + raw_url: z.string().optional(), + file_name: z.string().nullable().optional(), + files: z.array(GitLabSnippetFileSchema).optional(), + project_id: z.number().nullable().optional(), + }) + .passthrough(); + +export type GitLabSnippet = z.infer; +export type ListSnippetsOptions = z.infer; +export type GetSnippetOptions = z.infer; +export type CreateSnippetOptions = z.infer; +export type UpdateSnippetOptions = z.infer; +export type DeleteSnippetOptions = z.infer; + export const GitLabTagSignatureSchema = z.object({ signature_type: z.literal("X509"), verification_status: z.string(), diff --git a/scripts/generate-tool-docs.ts b/scripts/generate-tool-docs.ts index 57b0f8fd5..7f530fc50 100644 --- a/scripts/generate-tool-docs.ts +++ b/scripts/generate-tool-docs.ts @@ -140,6 +140,11 @@ const GROUP_META: Record = { blurb: "AI-assisted vulnerability triage — list findings, inspect details, dismiss with reason, or confirm for remediation. Backed by the GitLab GraphQL API; requires GitLab Ultimate.", }, + snippets: { + title: "Snippets", + blurb: + "Snippet CRUD — list, get (with optional file content), create, update, and delete personal or project snippets.", + }, }; const GROUP_ORDER: ToolsetId[] = [ @@ -163,6 +168,7 @@ const GROUP_ORDER: ToolsetId[] = [ "search", "dependency_proxy", "vulnerabilities", + "snippets", ]; // --- Helpers -------------------------------------------------------------- diff --git a/skills/gitlab-mcp/SKILL.md b/skills/gitlab-mcp/SKILL.md index 04370b2fe..48023d1bc 100644 --- a/skills/gitlab-mcp/SKILL.md +++ b/skills/gitlab-mcp/SKILL.md @@ -5,7 +5,7 @@ description: Use this skill when working with the GitLab MCP server tools for me # gitlab-mcp -GitLab MCP server providing 258 tools: 256 tools across 20 toolsets, plus `execute_graphql` and the always-available `discover_tools` meta-tool. +GitLab MCP server providing 263 tools: 261 tools across 21 toolsets, plus `execute_graphql` and the always-available `discover_tools` meta-tool. For exact generated parameter tables, see `docs/tools/`. Use this file for workflow shape and high-signal parameter hints. @@ -33,6 +33,7 @@ For exact generated parameter tables, see `docs/tools/`. Use this file for workf | variables (10 tools) | no | `GITLAB_TOOLSETS=variables` | | dependency_proxy (4 tools) | no | `GITLAB_TOOLSETS=dependency_proxy` | | vulnerabilities (4 tools) | no | `GITLAB_TOOLSETS=vulnerabilities` | +| snippets (5 tools) | no | `GITLAB_TOOLSETS=snippets` | Enable all: `GITLAB_TOOLSETS=all`. Use `GITLAB_TOOLS` to enable individual tools outside their toolset. `discover_tools` can list and activate opt-in categories for the current session. `execute_graphql` is not in a toolset; enable it explicitly with `GITLAB_TOOLS=execute_graphql`. @@ -120,7 +121,7 @@ Enable with `GITLAB_TOOLSETS=vulnerabilities` (requires GitLab Ultimate). ## Destructive Tools (require caution) -`delete_branch`, `delete_deployment`, `approve_deployment`, `delete_draft_note`, `delete_environment`, `erase_pipeline_job`, `delete_group_milestone`, `delete_group_variable`, `delete_group_wiki_page`, `delete_issue`, `delete_issue_emoji_reaction`, `delete_issue_link`, `delete_issue_note_emoji_reaction`, `delete_label`, `delete_merge_request_discussion_note`, `delete_merge_request_emoji_reaction`, `delete_merge_request_note`, `delete_merge_request_note_emoji_reaction`, `delete_milestone`, `delete_pipeline`, `delete_pipeline_schedule`, `delete_pipeline_schedule_variable`, `delete_pipeline_trigger`, `delete_project_variable`, `delete_release`, `delete_review_app_environments`, `delete_tag`, `delete_webhook`, `delete_wiki_page`, `delete_work_item_emoji_reaction`, `delete_work_item_note_emoji_reaction`, `merge_merge_request`, `protect_branch`, `purge_dependency_proxy_cache`, `push_files`, `unprotect_branch`, `update_default_branch` +`delete_branch`, `delete_deployment`, `approve_deployment`, `delete_draft_note`, `delete_environment`, `erase_pipeline_job`, `delete_group_milestone`, `delete_group_variable`, `delete_group_wiki_page`, `delete_issue`, `delete_issue_emoji_reaction`, `delete_issue_link`, `delete_issue_note_emoji_reaction`, `delete_label`, `delete_merge_request_discussion_note`, `delete_merge_request_emoji_reaction`, `delete_merge_request_note`, `delete_merge_request_note_emoji_reaction`, `delete_milestone`, `delete_pipeline`, `delete_pipeline_schedule`, `delete_pipeline_schedule_variable`, `delete_pipeline_trigger`, `delete_project_variable`, `delete_release`, `delete_review_app_environments`, `delete_snippet`, `delete_tag`, `delete_webhook`, `delete_wiki_page`, `delete_work_item_emoji_reaction`, `delete_work_item_note_emoji_reaction`, `merge_merge_request`, `protect_branch`, `purge_dependency_proxy_cache`, `push_files`, `unprotect_branch`, `update_default_branch` ## Advanced diff --git a/test/test-permission-mode.ts b/test/test-permission-mode.ts index 85d733976..696eac278 100644 --- a/test/test-permission-mode.ts +++ b/test/test-permission-mode.ts @@ -76,6 +76,7 @@ const DELETE_SAMPLE_TOOLS = [ "delete_group_milestone", "delete_project_variable", "delete_webhook", + "delete_snippet", ]; const MODIFY_SAMPLE_TOOLS = [ diff --git a/test/test-snippets.ts b/test/test-snippets.ts new file mode 100644 index 000000000..1ea25ecad --- /dev/null +++ b/test/test-snippets.ts @@ -0,0 +1,1191 @@ +import { after, before, describe, test } from "node:test"; +import assert from "node:assert"; +import { spawn } from "child_process"; +import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js"; + +const MOCK_TOKEN = "glpat-mock-token-snippets"; +const TEST_PROJECT_ID = "456"; +const TEST_PROJECT_SNIPPET_ID = 42; +const TEST_PERSONAL_SNIPPET_ID = 99; +const TEST_MULTIFILE_SNIPPET_ID = 77; +const TEST_MASTER_SNIPPET_ID = 88; +const TEST_SLASH_REF_SNIPPET_ID = 101; +const TEST_EXPLICIT_REF_SNIPPET_ID = 102; +const TEST_NESTED_PATH_SNIPPET_ID = 103; +const TEST_NO_VISIBILITY_SNIPPET_ID = 104; +const TEST_NO_RAWURL_SNIPPET_ID = 105; +const TEST_SINGLE_EXPLICIT_REF_SNIPPET_ID = 106; +const TEST_SINGLE_FILENAME_REF_SNIPPET_ID = 107; +const RAW_CONTENT = "console.log('hello world');\n"; +const SINGLE_REF_V2_CONTENT = "console.log('v2 branch');\n"; +const SINGLE_FILENAME_V3_CONTENT = "console.log('v3 filename fallback');\n"; +const MULTIFILE_A_CONTENT = "# policy\nbody A\n"; +const MULTIFILE_B_CONTENT = "# instructions\nbody B\n"; + +function buildSnippet(overrides: Record = {}) { + return { + id: TEST_PROJECT_SNIPPET_ID, + title: "Example snippet", + description: "An example snippet", + visibility: "private", + author: { + id: 1, + username: "tester", + name: "Tester", + state: "active", + avatar_url: null, + web_url: "https://gitlab.example.com/tester", + }, + file_name: "hello.js", + files: [{ path: "hello.js", raw_url: "https://gitlab.example.com/raw" }], + web_url: "https://gitlab.example.com/-/snippets/42", + raw_url: "https://gitlab.example.com/-/snippets/42/raw", + created_at: "2026-05-10T10:00:00.000Z", + updated_at: "2026-05-11T10:00:00.000Z", + project_id: Number(TEST_PROJECT_ID), + ...overrides, + }; +} + +async function callTool( + toolName: string, + args: Record, + env: NodeJS.ProcessEnv +): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("node", ["build/index.js"], { + stdio: ["pipe", "pipe", "pipe"], + env: { + ...process.env, + ...env, + }, + }); + + let output = ""; + let errorOutput = ""; + proc.stdout?.on("data", (d: Buffer) => (output += d)); + proc.stderr?.on("data", (d: Buffer) => (errorOutput += d)); + + proc.on("close", code => { + if (code !== 0) { + return reject(new Error(`Process exited with code ${code}: ${errorOutput}`)); + } + + const line = output.split("\n").find(l => l.startsWith("{")); + if (!line) { + return reject(new Error("No JSON output found")); + } + + try { + const response = JSON.parse(line); + if (response.error) { + reject(response.error); + return; + } + + const content = response.result?.content?.[0]?.text; + if (!content) { + resolve(response.result); + return; + } + + try { + resolve(JSON.parse(content)); + } catch { + resolve(content); + } + } catch (error) { + reject(error); + } + }); + + proc.stdin?.end( + JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: toolName, arguments: args }, + }) + "\n" + ); + }); +} + +describe("snippet tools", () => { + let mockGitLab: MockGitLabServer; + let mockGitLabUrl: string; + let lastCreateBody: any = null; + let lastUpdateBody: any = null; + let deleteCalled = false; + + before(async () => { + const mockPort = await findMockServerPort(); + mockGitLab = new MockGitLabServer({ + port: mockPort, + validTokens: [MOCK_TOKEN], + }); + + // --- Project snippet handlers --- + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets`, + (req, res) => { + assert.strictEqual(req.query.per_page, "5"); + res.json([buildSnippet()]); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_PROJECT_SNIPPET_ID}`, + (_req, res) => { + res.json(buildSnippet()); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_PROJECT_SNIPPET_ID}/raw`, + (_req, res) => { + res.type("text/plain").send(RAW_CONTENT); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_SINGLE_EXPLICIT_REF_SNIPPET_ID}`, + (_req, res) => { + res.json( + buildSnippet({ + id: TEST_SINGLE_EXPLICIT_REF_SNIPPET_ID, + title: "Single-file explicit ref snippet", + files: [{ path: "hello.js" }], + }) + ); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_SINGLE_EXPLICIT_REF_SNIPPET_ID}/raw`, + (_req, res) => { + res.type("text/plain").send(RAW_CONTENT); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_SINGLE_EXPLICIT_REF_SNIPPET_ID}/files/v2/hello.js/raw`, + (_req, res) => { + res.type("text/plain").send(SINGLE_REF_V2_CONTENT); + } + ); + + // Single-file snippet with no files[] entries: path must resolve from file_name. + // No /raw handler is registered, so falling back to /raw fails the test. + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_SINGLE_FILENAME_REF_SNIPPET_ID}`, + (_req, res) => { + res.json( + buildSnippet({ + id: TEST_SINGLE_FILENAME_REF_SNIPPET_ID, + title: "Single-file filename fallback snippet", + file_name: "app.js", + files: [], + }) + ); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_SINGLE_FILENAME_REF_SNIPPET_ID}/files/v3/app.js/raw`, + (_req, res) => { + res.type("text/plain").send(SINGLE_FILENAME_V3_CONTENT); + } + ); + + mockGitLab.addMockHandler( + "post", + `/projects/${TEST_PROJECT_ID}/snippets`, + (req, res) => { + lastCreateBody = req.body; + res.status(201).json( + buildSnippet({ + id: 100, + title: req.body.title, + description: req.body.description ?? null, + visibility: req.body.visibility, + file_name: req.body.files?.[0]?.file_path, + }) + ); + } + ); + + mockGitLab.addMockHandler( + "put", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_PROJECT_SNIPPET_ID}`, + (req, res) => { + lastUpdateBody = req.body; + res.json( + buildSnippet({ + title: req.body.title ?? "Example snippet", + description: req.body.description ?? "An example snippet", + visibility: req.body.visibility ?? "private", + }) + ); + } + ); + + mockGitLab.addMockHandler( + "delete", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_PROJECT_SNIPPET_ID}`, + (_req, res) => { + deleteCalled = true; + res.status(204).send(); + } + ); + + // --- Multi-file snippet handlers --- + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_MULTIFILE_SNIPPET_ID}`, + (_req, res) => { + res.json( + buildSnippet({ + id: TEST_MULTIFILE_SNIPPET_ID, + title: "Multi-file snippet", + file_name: null, + files: [ + { + path: "policy.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_MULTIFILE_SNIPPET_ID}/raw/main/policy.md`, + }, + { + path: "instructions.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_MULTIFILE_SNIPPET_ID}/raw/main/instructions.md`, + }, + ], + }) + ); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_MULTIFILE_SNIPPET_ID}/files/main/policy.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_A_CONTENT); } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_MULTIFILE_SNIPPET_ID}/files/main/instructions.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_B_CONTENT); } + ); + + // --- Snippet with master default branch (raw_url contains "master") --- + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_MASTER_SNIPPET_ID}`, + (_req, res) => { + // No default_branch field — simulates an instance where it's absent + res.json( + buildSnippet({ + id: TEST_MASTER_SNIPPET_ID, + title: "Master branch snippet", + file_name: null, + files: [ + { + path: "policy.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_MASTER_SNIPPET_ID}/raw/master/policy.md`, + }, + { + path: "instructions.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_MASTER_SNIPPET_ID}/raw/master/instructions.md`, + }, + ], + }) + ); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_MASTER_SNIPPET_ID}/files/master/policy.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_A_CONTENT); } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_MASTER_SNIPPET_ID}/files/master/instructions.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_B_CONTENT); } + ); + + // --- Snippet with slash-containing ref (e.g. feature/foo) --- + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_SLASH_REF_SNIPPET_ID}`, + (_req, res) => { + res.json( + buildSnippet({ + id: TEST_SLASH_REF_SNIPPET_ID, + title: "Slash ref snippet", + file_name: null, + files: [ + { + path: "policy.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_SLASH_REF_SNIPPET_ID}/raw/feature/foo/policy.md`, + }, + { + path: "instructions.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_SLASH_REF_SNIPPET_ID}/raw/feature/foo/instructions.md`, + }, + ], + }) + ); + } + ); + + // ref "feature/foo" must arrive as "feature%2Ffoo" in the API URL + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_SLASH_REF_SNIPPET_ID}/files/feature%2Ffoo/policy.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_A_CONTENT); } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_SLASH_REF_SNIPPET_ID}/files/feature%2Ffoo/instructions.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_B_CONTENT); } + ); + + // --- Explicit ref snippet: raw_url carries ref "wrong" but caller overrides with "v2" --- + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_EXPLICIT_REF_SNIPPET_ID}`, + (_req, res) => { + res.json( + buildSnippet({ + id: TEST_EXPLICIT_REF_SNIPPET_ID, + title: "Explicit ref snippet", + file_name: null, + files: [ + { + path: "a.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_EXPLICIT_REF_SNIPPET_ID}/raw/wrong/a.md`, + }, + { + path: "b.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_EXPLICIT_REF_SNIPPET_ID}/raw/wrong/b.md`, + }, + ], + }) + ); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_EXPLICIT_REF_SNIPPET_ID}/files/v2/a.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_A_CONTENT); } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_EXPLICIT_REF_SNIPPET_ID}/files/v2/b.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_B_CONTENT); } + ); + + // --- Nested file path snippet (file_path with a "/" inside) --- + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_NESTED_PATH_SNIPPET_ID}`, + (_req, res) => { + res.json( + buildSnippet({ + id: TEST_NESTED_PATH_SNIPPET_ID, + title: "Nested path snippet", + file_name: null, + files: [ + { + path: "dir/policy.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_NESTED_PATH_SNIPPET_ID}/raw/main/dir/policy.md`, + }, + { + path: "other/sub/instructions.md", + raw_url: `${mockGitLabUrl}/-/snippets/${TEST_NESTED_PATH_SNIPPET_ID}/raw/main/other/sub/instructions.md`, + }, + ], + }) + ); + } + ); + + // Per GitLab docs, file_path must be a single URL-encoded segment, so + // "dir/policy.md" arrives as "dir%2Fpolicy.md" in the API URL. + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_NESTED_PATH_SNIPPET_ID}/files/main/dir%2Fpolicy.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_A_CONTENT); } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_NESTED_PATH_SNIPPET_ID}/files/main/other%2Fsub%2Finstructions.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_B_CONTENT); } + ); + + // --- Snippet response missing visibility (per GitLab project snippets API shape) --- + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_NO_VISIBILITY_SNIPPET_ID}`, + (_req, res) => { + const snippet: Record = buildSnippet({ + id: TEST_NO_VISIBILITY_SNIPPET_ID, + title: "No visibility snippet", + }); + delete snippet.visibility; + res.json(snippet); + } + ); + + // --- Snippet whose files[] entries omit raw_url (only `path` present) --- + // Used to verify get_snippet(include_content=true, ref=...) succeeds when + // the caller provides ref explicitly, without parsing raw_url. + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_NO_RAWURL_SNIPPET_ID}`, + (_req, res) => { + res.json( + buildSnippet({ + id: TEST_NO_RAWURL_SNIPPET_ID, + title: "No raw_url snippet", + file_name: null, + files: [ + { path: "policy.md" }, + { path: "instructions.md" }, + ], + }) + ); + } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_NO_RAWURL_SNIPPET_ID}/files/main/policy.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_A_CONTENT); } + ); + + mockGitLab.addMockHandler( + "get", + `/projects/${TEST_PROJECT_ID}/snippets/${TEST_NO_RAWURL_SNIPPET_ID}/files/main/instructions.md/raw`, + (_req, res) => { res.type("text/plain").send(MULTIFILE_B_CONTENT); } + ); + + // --- Personal snippet handlers --- + mockGitLab.addMockHandler("get", "/snippets", (_req, res) => { + res.json([ + buildSnippet({ + id: TEST_PERSONAL_SNIPPET_ID, + title: "Personal snippet", + project_id: null, + }), + ]); + }); + + mockGitLab.addMockHandler( + "get", + `/snippets/${TEST_PERSONAL_SNIPPET_ID}`, + (_req, res) => { + res.json( + buildSnippet({ + id: TEST_PERSONAL_SNIPPET_ID, + title: "Personal snippet", + project_id: null, + }) + ); + } + ); + + // --- URL-encoded project path handler --- + // Express keeps "%2F" literal in req.path (it never collapses encoded slashes + // into path separators), so the mock key is the single-encoded form. + // Correct: caller decodes "my-group%2Fmy-project" → "my-group/my-project" → + // re-encodes → "my-group%2Fmy-project" → req.path is "/projects/my-group%2Fmy-project/snippets". + // Double-encoded bug: caller encodes "my-group%2Fmy-project" → "my-group%252Fmy-project" + // → req.path is "/projects/my-group%252Fmy-project/snippets" (no handler match). + mockGitLab.addMockHandler( + "get", + "/projects/my-group%2Fmy-project/snippets", + (_req, res) => { + res.json([buildSnippet({ id: 555, title: "Encoded path snippet" })]); + } + ); + + mockGitLab.addMockHandler("post", "/snippets", (req, res) => { + res.status(201).json( + buildSnippet({ + id: 200, + title: req.body.title, + description: req.body.description ?? null, + visibility: req.body.visibility, + file_name: req.body.files?.[0]?.file_path, + project_id: null, + }) + ); + }); + + await mockGitLab.start(); + mockGitLabUrl = mockGitLab.getUrl(); + }); + + after(async () => { + await mockGitLab.stop(); + }); + + const env = () => ({ + GITLAB_API_URL: `${mockGitLabUrl}/api/v4`, + GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN, + GITLAB_TOOLSETS: "snippets", + }); + + test("list_snippets returns project snippets", async () => { + const result = await callTool( + "list_snippets", + { project_id: TEST_PROJECT_ID, per_page: 5 }, + env() + ); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, TEST_PROJECT_SNIPPET_ID); + assert.strictEqual(result[0].title, "Example snippet"); + }); + + test("list_snippets returns personal snippets when project_id omitted", async () => { + const result = await callTool("list_snippets", {}, env()); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, TEST_PERSONAL_SNIPPET_ID); + assert.strictEqual(result[0].project_id, null); + }); + + test("get_snippet returns metadata without content by default", async () => { + const result = await callTool( + "get_snippet", + { project_id: TEST_PROJECT_ID, snippet_id: TEST_PROJECT_SNIPPET_ID }, + env() + ); + + assert.strictEqual(result.id, TEST_PROJECT_SNIPPET_ID); + assert.strictEqual(result.title, "Example snippet"); + assert.strictEqual(result.content, undefined); + }); + + test("get_snippet with include_content fetches raw content", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + include_content: true, + }, + env() + ); + + assert.strictEqual(result.id, TEST_PROJECT_SNIPPET_ID); + assert.strictEqual(result.content, RAW_CONTENT); + }); + + test("get_snippet with include_content uses explicit ref for single-file snippet", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_SINGLE_EXPLICIT_REF_SNIPPET_ID, + include_content: true, + ref: "v2", + }, + env() + ); + + assert.strictEqual(result.id, TEST_SINGLE_EXPLICIT_REF_SNIPPET_ID); + assert.strictEqual(result.content, SINGLE_REF_V2_CONTENT); + }); + + test("get_snippet with include_content resolves single-file path from file_name", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_SINGLE_FILENAME_REF_SNIPPET_ID, + include_content: true, + ref: "v3", + }, + env() + ); + + assert.strictEqual(result.id, TEST_SINGLE_FILENAME_REF_SNIPPET_ID); + assert.strictEqual(result.content, SINGLE_FILENAME_V3_CONTENT); + }); + + test("get_snippet with include_content fetches per-file raw for multi-file snippet", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_MULTIFILE_SNIPPET_ID, + include_content: true, + }, + env() + ); + + assert.strictEqual(result.id, TEST_MULTIFILE_SNIPPET_ID); + assert.strictEqual(result.content, undefined); + assert.ok(Array.isArray(result.files)); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0].path, "policy.md"); + assert.strictEqual(result.files[0].content, MULTIFILE_A_CONTENT); + assert.strictEqual(result.files[1].path, "instructions.md"); + assert.strictEqual(result.files[1].content, MULTIFILE_B_CONTENT); + }); + + test("create_snippet posts files array with file_path and content", async () => { + const result = await callTool( + "create_snippet", + { + project_id: TEST_PROJECT_ID, + title: "New snippet", + file_name: "demo.txt", + content: "hello", + description: "test desc", + visibility: "internal", + }, + env() + ); + + assert.strictEqual(result.id, 100); + assert.strictEqual(result.title, "New snippet"); + assert.strictEqual(result.visibility, "internal"); + assert.deepStrictEqual(lastCreateBody, { + title: "New snippet", + visibility: "internal", + files: [{ file_path: "demo.txt", content: "hello" }], + description: "test desc", + }); + }); + + test("create_snippet defaults to private visibility", async () => { + await callTool( + "create_snippet", + { + project_id: TEST_PROJECT_ID, + title: "Defaults", + file_name: "a.txt", + content: "x", + }, + env() + ); + + assert.strictEqual(lastCreateBody.visibility, "private"); + assert.strictEqual(lastCreateBody.description, undefined); + }); + + test("update_snippet sends only provided fields", async () => { + const result = await callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + title: "Renamed", + visibility: "public", + }, + env() + ); + + assert.strictEqual(result.title, "Renamed"); + assert.deepStrictEqual(lastUpdateBody, { + title: "Renamed", + visibility: "public", + }); + }); + + test("update_snippet with file_name and content uses files action: update", async () => { + await callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + file_name: "renamed.txt", + content: "new content", + }, + env() + ); + + assert.deepStrictEqual(lastUpdateBody, { + files: [{ action: "update", file_path: "renamed.txt", content: "new content" }], + }); + }); + + test("update_snippet supports files[] move action with previous_path", async () => { + await callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + files: [ + { action: "move", previous_path: "old.md", file_path: "new.md" }, + ], + }, + env() + ); + + assert.deepStrictEqual(lastUpdateBody, { + files: [{ action: "move", previous_path: "old.md", file_path: "new.md" }], + }); + }); + + test("update_snippet supports files[] delete and create actions", async () => { + await callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + files: [ + { action: "delete", file_path: "gone.md" }, + { action: "create", file_path: "added.md", content: "new body" }, + ], + }, + env() + ); + + assert.deepStrictEqual(lastUpdateBody, { + files: [ + { action: "delete", file_path: "gone.md" }, + { action: "create", file_path: "added.md", content: "new body" }, + ], + }); + }); + + test("update_snippet rejects files[] move action without previous_path at parse time", async () => { + await assert.rejects( + () => + callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + files: [{ action: "move", file_path: "new.md" }], + }, + env() + ), + (err: any) => /previous_path.*required.*move/i.test(JSON.stringify(err)) + ); + }); + + test("update_snippet rejects mixing files[] with file_name/content", async () => { + await assert.rejects( + () => + callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + file_name: "a.txt", + content: "x", + files: [{ action: "update", file_path: "a.txt", content: "y" }], + }, + env() + ), + (err: any) => /Cannot mix files\[\] with file_name\/content/.test(JSON.stringify(err)) + ); + }); + + test("delete_snippet calls DELETE and returns success", async () => { + deleteCalled = false; + const result = await callTool( + "delete_snippet", + { project_id: TEST_PROJECT_ID, snippet_id: TEST_PROJECT_SNIPPET_ID }, + env() + ); + + assert.strictEqual(deleteCalled, true); + assert.deepStrictEqual(result, { + status: "success", + message: `Snippet ${TEST_PROJECT_SNIPPET_ID} deleted successfully`, + }); + }); + + test("get_snippet fetches multi-file content from raw_url regardless of branch name", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_MASTER_SNIPPET_ID, + include_content: true, + }, + env() + ); + + assert.strictEqual(result.id, TEST_MASTER_SNIPPET_ID); + assert.ok(Array.isArray(result.files)); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0].path, "policy.md"); + assert.strictEqual(result.files[0].content, MULTIFILE_A_CONTENT); + assert.strictEqual(result.files[1].path, "instructions.md"); + assert.strictEqual(result.files[1].content, MULTIFILE_B_CONTENT); + }); + + test("get_snippet with include_content encodes slashes in ref as %2F", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_SLASH_REF_SNIPPET_ID, + include_content: true, + }, + env() + ); + + assert.strictEqual(result.id, TEST_SLASH_REF_SNIPPET_ID); + assert.ok(Array.isArray(result.files)); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0].path, "policy.md"); + assert.strictEqual(result.files[0].content, MULTIFILE_A_CONTENT); + assert.strictEqual(result.files[1].path, "instructions.md"); + assert.strictEqual(result.files[1].content, MULTIFILE_B_CONTENT); + }); + + test("get_snippet with include_content uses explicit ref when provided, ignoring raw_url ref", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_EXPLICIT_REF_SNIPPET_ID, + include_content: true, + ref: "v2", + }, + env() + ); + + assert.strictEqual(result.id, TEST_EXPLICIT_REF_SNIPPET_ID); + assert.ok(Array.isArray(result.files)); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0].content, MULTIFILE_A_CONTENT); + assert.strictEqual(result.files[1].content, MULTIFILE_B_CONTENT); + }); + + test("get_snippet with include_content and explicit ref succeeds when files[].raw_url is missing", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_NO_RAWURL_SNIPPET_ID, + include_content: true, + ref: "main", + }, + env() + ); + + assert.strictEqual(result.id, TEST_NO_RAWURL_SNIPPET_ID); + assert.ok(Array.isArray(result.files)); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0].content, MULTIFILE_A_CONTENT); + assert.strictEqual(result.files[1].content, MULTIFILE_B_CONTENT); + }); + + test("get_snippet with include_content still throws when both raw_url and ref are missing", async () => { + await assert.rejects( + () => + callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_NO_RAWURL_SNIPPET_ID, + include_content: true, + }, + env() + ), + (err: any) => /has no raw_url/.test(JSON.stringify(err)) + ); + }); + + test("get_snippet with include_content encodes nested file paths as a single segment", async () => { + const result = await callTool( + "get_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_NESTED_PATH_SNIPPET_ID, + include_content: true, + }, + env() + ); + + assert.strictEqual(result.id, TEST_NESTED_PATH_SNIPPET_ID); + assert.ok(Array.isArray(result.files)); + assert.strictEqual(result.files.length, 2); + assert.strictEqual(result.files[0].path, "dir/policy.md"); + assert.strictEqual(result.files[0].content, MULTIFILE_A_CONTENT); + assert.strictEqual(result.files[1].path, "other/sub/instructions.md"); + assert.strictEqual(result.files[1].content, MULTIFILE_B_CONTENT); + }); + + test("create_snippet supports multi-file snippets via files[]", async () => { + const result = await callTool( + "create_snippet", + { + project_id: TEST_PROJECT_ID, + title: "Multi-file", + files: [ + { file_path: "policy.md", content: "policy body" }, + { file_path: "instructions.md", content: "instructions body" }, + ], + visibility: "internal", + }, + env() + ); + + assert.strictEqual(result.id, 100); + assert.deepStrictEqual(lastCreateBody, { + title: "Multi-file", + visibility: "internal", + files: [ + { file_path: "policy.md", content: "policy body" }, + { file_path: "instructions.md", content: "instructions body" }, + ], + }); + }); + + test("create_snippet rejects mixing files[] with file_name/content", async () => { + await assert.rejects( + () => + callTool( + "create_snippet", + { + project_id: TEST_PROJECT_ID, + title: "Bad mix", + file_name: "a.txt", + content: "x", + files: [{ file_path: "b.txt", content: "y" }], + }, + env() + ), + (err: any) => /Cannot mix files\[\] with file_name\/content/.test(JSON.stringify(err)) + ); + }); + + test("create_snippet rejects when neither files[] nor file_name+content provided", async () => { + await assert.rejects( + () => + callTool( + "create_snippet", + { + project_id: TEST_PROJECT_ID, + title: "Empty", + }, + env() + ), + (err: any) => + /Provide either files\[\] .* or both file_name and content/.test(JSON.stringify(err)) + ); + }); + + test("update_snippet rejects files[] create action without content at parse time", async () => { + await assert.rejects( + () => + callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + files: [{ action: "create", file_path: "a.md" }], + }, + env() + ), + (err: any) => /content is required for action: 'create'/.test(JSON.stringify(err)) + ); + }); + + test("update_snippet rejects files[] update action without content at parse time", async () => { + await assert.rejects( + () => + callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + files: [{ action: "update", file_path: "a.md" }], + }, + env() + ), + (err: any) => /content is required for action: 'update'/.test(JSON.stringify(err)) + ); + }); + + test("update_snippet rejects files[] action without file_path at parse time", async () => { + await assert.rejects( + () => + callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + files: [{ action: "delete" }], + }, + env() + ), + (err: any) => /file_path is required/.test(JSON.stringify(err)) + ); + }); + + test("update_snippet rejects empty files[] array at parse time", async () => { + await assert.rejects( + () => + callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + files: [], + }, + env() + ), + (err: any) => /files\[\] must be non-empty/.test(JSON.stringify(err)) + ); + }); + + test("update_snippet rejects file_name without content at parse time", async () => { + await assert.rejects( + () => + callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + file_name: "new-name.txt", + }, + env() + ), + (err: any) => /file_name requires content/.test(JSON.stringify(err)) + ); + }); + + test("update_snippet rejects content without file_name at parse time", async () => { + await assert.rejects( + () => + callTool( + "update_snippet", + { + project_id: TEST_PROJECT_ID, + snippet_id: TEST_PROJECT_SNIPPET_ID, + content: "orphan content", + }, + env() + ), + (err: any) => /content requires file_name/.test(JSON.stringify(err)) + ); + }); + + test("create_snippet without project_id hits personal endpoint", async () => { + const result = await callTool( + "create_snippet", + { + title: "Personal new", + file_name: "note.md", + content: "body", + visibility: "public", + }, + env() + ); + + assert.strictEqual(result.id, 200); + assert.strictEqual(result.title, "Personal new"); + assert.strictEqual(result.visibility, "public"); + assert.strictEqual(result.project_id, null); + }); + + test("list_snippets does not double-encode URL-encoded project paths", async () => { + const result = await callTool( + "list_snippets", + { project_id: "my-group%2Fmy-project" }, + env() + ); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, 555); + assert.strictEqual(result[0].title, "Encoded path snippet"); + }); + + test("get_snippet accepts responses with no visibility field", async () => { + const result = await callTool( + "get_snippet", + { project_id: TEST_PROJECT_ID, snippet_id: TEST_NO_VISIBILITY_SNIPPET_ID }, + env() + ); + + assert.strictEqual(result.id, TEST_NO_VISIBILITY_SNIPPET_ID); + assert.strictEqual(result.title, "No visibility snippet"); + assert.strictEqual(result.visibility, undefined); + }); + + test("list_snippets without project_id routes through GITLAB_PROJECT_ID when set", async () => { + const result = await callTool( + "list_snippets", + { per_page: 5 }, + { ...env(), GITLAB_PROJECT_ID: TEST_PROJECT_ID } + ); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, TEST_PROJECT_SNIPPET_ID); + }); + + test("get_snippet without project_id routes through GITLAB_PROJECT_ID when set", async () => { + const result = await callTool( + "get_snippet", + { snippet_id: TEST_PROJECT_SNIPPET_ID }, + { ...env(), GITLAB_PROJECT_ID: TEST_PROJECT_ID } + ); + + assert.strictEqual(result.id, TEST_PROJECT_SNIPPET_ID); + assert.strictEqual(result.project_id, Number(TEST_PROJECT_ID)); + }); + + test("list_snippets without project_id routes through single GITLAB_ALLOWED_PROJECT_IDS", async () => { + const result = await callTool( + "list_snippets", + { per_page: 5 }, + { ...env(), GITLAB_ALLOWED_PROJECT_IDS: TEST_PROJECT_ID } + ); + + assert.ok(Array.isArray(result)); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].id, TEST_PROJECT_SNIPPET_ID); + }); + + test("list_snippets without project_id rejects when GITLAB_ALLOWED_PROJECT_IDS has multiple entries", async () => { + await assert.rejects( + () => + callTool( + "list_snippets", + {}, + { ...env(), GITLAB_ALLOWED_PROJECT_IDS: `${TEST_PROJECT_ID},789` } + ), + (err: any) => /Multiple projects allowed/.test(JSON.stringify(err)) + ); + }); + + test("get_snippet without project_id hits personal endpoint", async () => { + const result = await callTool( + "get_snippet", + { snippet_id: TEST_PERSONAL_SNIPPET_ID }, + env() + ); + + assert.strictEqual(result.id, TEST_PERSONAL_SNIPPET_ID); + assert.strictEqual(result.title, "Personal snippet"); + assert.strictEqual(result.project_id, null); + }); +}); diff --git a/test/test-toolset-filtering.ts b/test/test-toolset-filtering.ts index 0cd3852c3..233b0414f 100644 --- a/test/test-toolset-filtering.ts +++ b/test/test-toolset-filtering.ts @@ -42,6 +42,7 @@ const TOOLSET_TOOL_COUNTS: Record = { wiki: 10, releases: 7, tags: 5, + snippets: 5, users: 7, search: 3, workitems: 18, @@ -73,6 +74,7 @@ const NON_DEFAULT_TOOLSETS = [ "wiki", "releases", "tags", + "snippets", "workitems", "webhooks", "search", @@ -108,6 +110,7 @@ const TOOLSET_SAMPLE_TOOLS: Record = { wiki: ["list_wiki_pages", "create_wiki_page", "list_group_wiki_pages", "create_group_wiki_page"], releases: ["list_releases", "create_release", "download_release_asset"], tags: ["list_tags", "create_tag", "get_tag_signature"], + snippets: ["list_snippets", "create_snippet", "update_snippet"], users: ["get_users", "upload_markdown", "download_attachment"], search: ["search_code", "search_project_code", "search_group_code"], webhooks: ["list_webhooks", "create_webhook", "update_webhook", "delete_webhook", "list_webhook_events", "get_webhook_event"], diff --git a/tools/registry.ts b/tools/registry.ts index 7a913fc76..2c41365f9 100644 --- a/tools/registry.ts +++ b/tools/registry.ts @@ -231,6 +231,11 @@ import { SearchGroupCodeSchema, SearchProjectCodeSchema, SearchRepositoriesSchema, + ListSnippetsSchema, + GetSnippetSchema, + CreateSnippetSchema, + UpdateSnippetSchema, + DeleteSnippetSchema, UnapproveMergeRequestSchema, UpdateDraftNoteSchema, UpdateGroupWikiPageSchema, @@ -1264,6 +1269,36 @@ export const allTools = [ description: "Get the X.509 signature of a signed tag (404 if unsigned)", inputSchema: toJSONSchema(GetTagSignatureSchema), }, + // --- Snippet tools --- + { + name: "list_snippets", + description: + "List snippets — project snippets when project_id is given, otherwise personal snippets", + inputSchema: toJSONSchema(ListSnippetsSchema), + }, + { + name: "get_snippet", + description: + "Get a snippet's metadata. Set include_content=true to also fetch the raw file content.", + inputSchema: toJSONSchema(GetSnippetSchema), + }, + { + name: "create_snippet", + description: + "Create a snippet — project-scoped when project_id is given, otherwise a personal snippet. Supports single-file (file_name + content) or multi-file (files[]).", + inputSchema: toJSONSchema(CreateSnippetSchema), + }, + { + name: "update_snippet", + description: + "Update an existing snippet (provide at least one field to change). For multi-file edits — renames, deletions, additions — pass files[] with action (create/update/delete/move) and previous_path. The file_name + content shortcut still works for single-file content replacement.", + inputSchema: toJSONSchema(UpdateSnippetSchema), + }, + { + name: "delete_snippet", + description: "Delete a snippet", + inputSchema: toJSONSchema(DeleteSnippetSchema), + }, // --- Work item tools (GraphQL-based) --- { name: "get_work_item", @@ -1623,6 +1658,8 @@ export const readOnlyTools = new Set([ "list_tags", "get_tag", "get_tag_signature", + "list_snippets", + "get_snippet", "get_merge_request_approval_state", "get_work_item", "list_work_items", @@ -1669,6 +1706,7 @@ export const destructiveTools = new Set([ "delete_release", "delete_tag", "delete_webhook", + "delete_snippet", "delete_merge_request_note", "delete_merge_request_discussion_note", "delete_draft_note", @@ -1719,6 +1757,7 @@ export const deleteTools = new Set([ "delete_pipeline_schedule_variable", "delete_project_variable", "delete_release", + "delete_snippet", "delete_tag", "delete_webhook", "delete_wiki_page", @@ -1841,6 +1880,7 @@ export type ToolsetId = | "wiki" | "releases" | "tags" + | "snippets" | "users" | "workitems" | "webhooks" @@ -2138,6 +2178,17 @@ export const TOOLSET_DEFINITIONS: readonly ToolsetDefinition[] = [ "get_tag_signature", ]), }, + { + id: "snippets", + isDefault: false, + tools: new Set([ + "list_snippets", + "get_snippet", + "create_snippet", + "update_snippet", + "delete_snippet", + ]), + }, { id: "users", isDefault: true,