Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,11 @@ Register the skill directory in your AI client to get optimal tool usage guidanc
152. `search_group_code` - Search for code within a specific GitLab group (requires advanced search or exact code search to be enabled)
153. `execute_graphql` - Execute a GitLab GraphQL query
154. `list_merge_request_pipelines` - List pipelines for a merge request with pagination support
155. `list_snippets` - List snippets — project snippets when project_id is given, otherwise personal snippets
156. `get_snippet` - Get a snippet's metadata. Set include_content=true to also fetch the raw file content
157. `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[])
158. `update_snippet` - Update an existing snippet (provide at least one field to change)
159. `delete_snippet` - Delete a snippet

<!-- TOOLS-END -->

Expand Down
282 changes: 282 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,13 @@ import {
GitLabTagSignatureSchema,
type GitLabTag,
type GitLabTagSignature,
ListSnippetsSchema,
GetSnippetSchema,
CreateSnippetSchema,
UpdateSnippetSchema,
DeleteSnippetSchema,
GitLabSnippetSchema,
type GitLabSnippet,
GetMergeRequestNotesSchema,
GetMergeRequestNoteSchema,
DeleteMergeRequestDiscussionNoteSchema,
Expand Down Expand Up @@ -8303,6 +8310,201 @@ async function getTagSignature(
return GitLabTagSignatureSchema.parse(data);
}

/**
* Build the snippets endpoint URL.
*
* When projectId is provided, or when project scoping is configured via
* GITLAB_PROJECT_ID / GITLAB_ALLOWED_PROJECT_IDS, returns the project snippets
* endpoint (routing through getEffectiveProjectId so the scope env is enforced).
* Only falls back to the personal /snippets endpoint when neither is set.
*/
function getSnippetsEndpoint(projectId?: string): string {
const scopeActive = GITLAB_ALLOWED_PROJECT_IDS.length > 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`;
Comment thread
zereight marked this conversation as resolved.
}

/**
* List snippets — project snippets if projectId is given, otherwise personal snippets.
*/
async function listSnippets(
projectId: string | undefined,
options: Omit<z.infer<typeof ListSnippetsSchema>, "project_id"> = {}
): Promise<GitLabSnippet[]> {
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<GitLabSnippet> {
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<string> {
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<string> {
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<z.infer<typeof CreateSnippetSchema>, "project_id">
): Promise<GitLabSnippet> {
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<string, unknown> = {
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<z.infer<typeof UpdateSnippetSchema>, "project_id">
): Promise<GitLabSnippet> {
const { snippet_id, title, file_name, content, files, description, visibility } = options;
const body: Record<string, unknown> = {};
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<void> {
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).

Expand Down Expand Up @@ -10404,6 +10606,86 @@ 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<string, unknown> = { ...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 {
result.content = await getSnippetRawContent(

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args.ref is ignored here because this branch always calls getSnippetRawContent. Use getSnippetFileRawContent when ref is provided and there is a resolvable file path (files[0].path or snippet.file_name).

args.project_id,
args.snippet_id
);
Comment thread
zereight marked this conversation as resolved.
}
}
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);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"changelog": "auto-changelog -p",
"test": "npm run test:all",
"test:all": "npm run build && npm run test:mock && npm run test:live",
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-snippets.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
"test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
"test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
"test:live": "node test/validate-api.js",
Expand Down
Loading