Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "gitlab-mcp",
"description": "GitLab MCP server with GraphQL discovery and team activity tools",
"version": "1.18.1",
"version": "1.19.0",
"icon": "assets/logo.svg",
"author": {
"name": "Tim Pearson"
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.19.0] - 2026-05-21

### Added
- `execute_rest_read` and `execute_rest_write` tools — open-ended REST API escape hatches matching the existing `execute_custom_query` for GraphQL. Lets agents reach any `/api/v4` endpoint not covered by a dedicated tool (admin endpoints, repo files, pipeline test reports, etc.) instead of waiting for a curated tool PR. Split into two so the read variant is `readOnlyHint: true` / `requiresWrite: false` and the write variant is `requiresWrite: true` / `destructiveHint: true` — mirrors the GITLAB_TOKEN / GITLAB_READ_TOKEN separation and lets the MCP harness gate destructive calls. Path is validated to be `/api/v4`-relative (no host, no query string, no `..` traversal) before the request runs.
- `restRequest` now accepts `PATCH` in addition to `GET` / `POST` / `PUT` / `DELETE` — some GitLab REST endpoints (e.g. certain MR approval-rule routes) use PATCH.

### Changed
- Removed a no-op `startsWith('gid://')` ternary in `markAllTodosDone` that resolved to `params.targetId` in both branches. Functionally identical; just cleaner.

## [1.18.1] - 2026-05-21

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ttpears/gitlab-mcp-server",
"version": "1.18.1",
"version": "1.19.0",
"description": "GitLab MCP Server with GraphQL discovery",
"main": "dist/index.js",
"module": "./src/index.ts",
Expand Down
60 changes: 58 additions & 2 deletions src/gitlab-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2889,7 +2889,7 @@ export class GitLabGraphQLClient {
): Promise<{ todos: Array<{ id: string; state: string }>; errors: string[] }> {
const input: Record<string, any> = {};
if (params.targetId) {
input.targetId = params.targetId.startsWith('gid://') ? params.targetId : params.targetId;
input.targetId = params.targetId;
}
if (params.authorIds && params.authorIds.length > 0) {
input.authorId = params.authorIds.map(id =>
Expand Down Expand Up @@ -2993,7 +2993,7 @@ export class GitLabGraphQLClient {
* (e.g., broadcast messages).
*/
private async restRequest<T = any>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
options: { body?: any; query?: Record<string, any>; userConfig?: UserConfig; requiresWrite?: boolean } = {}
): Promise<T> {
Expand Down Expand Up @@ -3048,6 +3048,62 @@ export class GitLabGraphQLClient {
}, `REST ${method} ${path}`);
}

/**
* Validate a REST path supplied by an MCP tool caller. Must be an absolute
* `/api/v4` sub-path; we reject schemes, query strings, and `..` segments
* so the caller can't escape the configured GitLab base URL.
*/
private validateRestPath(path: string): string {
const trimmed = path.trim();
if (!trimmed.startsWith('/')) {
throw new Error(`REST path must start with "/" (got: ${trimmed})`);
}
if (trimmed.includes('://')) {
throw new Error('REST path must be a path under /api/v4, not a full URL');
}
if (trimmed.includes('?')) {
throw new Error('REST path must not include a query string — use the query parameter instead');
}
if (trimmed.split('/').some(seg => seg === '..')) {
throw new Error('REST path must not contain ".." segments');
}
return trimmed;
}

/**
* Execute an arbitrary `GET /api/v4` request. Open-ended escape hatch for
* REST endpoints not covered by a dedicated tool. Reads only — uses the
* standard read-or-write token resolution (per-call > GITLAB_TOKEN >
* GITLAB_READ_TOKEN).
*/
async executeRestRead<T = any>(
path: string,
query?: Record<string, any>,
userConfig?: UserConfig,
): Promise<T> {
return this.restRequest<T>('GET', this.validateRestPath(path), { query, userConfig });
}

/**
* Execute an arbitrary write request (`POST` / `PUT` / `PATCH` / `DELETE`)
* against `/api/v4`. Open-ended escape hatch for write endpoints not
* covered by a dedicated tool. Requires write capability — the four-step
* token resolution rejects GITLAB_READ_TOKEN-only setups.
*/
async executeRestWrite<T = any>(
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE',
path: string,
options: { body?: any; query?: Record<string, any> } = {},
userConfig?: UserConfig,
): Promise<T> {
return this.restRequest<T>(method, this.validateRestPath(path), {
body: options.body,
query: options.query,
userConfig,
requiresWrite: true,
});
}

async listBroadcastMessages(page = 1, perPage = 20, userConfig?: UserConfig): Promise<any> {
return this.restRequest('GET', '/broadcast_messages', {
query: { page, per_page: Math.min(perPage, this.config.maxPageSize) },
Expand Down
57 changes: 57 additions & 0 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,61 @@ const executeCustomQueryTool: Tool = {
},
};

const executeRestReadTool: Tool = {
name: 'execute_rest_read',
title: 'Custom REST Read',
description:
'Execute an arbitrary GET request against the GitLab REST API at /api/v4. Open-ended escape hatch for read endpoints not covered by a dedicated tool — e.g. /projects/:id/repository/files, /projects/:id/pipelines/:pipeline_id/test_report, /admin/*. Provide the path beginning with "/" (no host, no /api/v4 prefix) and an optional query object. For writes, use execute_rest_write.',
requiresAuth: false,
requiresWrite: false,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
inputSchema: withUserAuth(z.object({
path: z
.string()
.min(1)
.describe('Path under /api/v4, beginning with "/" (e.g. "/projects/42/issues" or "/projects/foo%2Fbar/repository/commits/HEAD"). Must not include host, "/api/v4" prefix, or "?" query string.'),
query: z
.record(z.union([z.string(), z.number(), z.boolean()]))
.optional()
.describe('Query string parameters (e.g. { state: "opened", per_page: 20 }). Values are coerced to strings.'),
})),
handler: async (input, client, userConfig) => {
const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
return client.executeRestRead(input.path, input.query, credentials);
},
};

const executeRestWriteTool: Tool = {
name: 'execute_rest_write',
title: 'Custom REST Write',
description:
'Execute an arbitrary POST/PUT/PATCH/DELETE request against the GitLab REST API at /api/v4. Open-ended escape hatch for write endpoints not covered by a dedicated tool. Destructive — DELETE is permitted, so check the path before invoking. For reads, use execute_rest_read.',
requiresAuth: false,
requiresWrite: true,
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
inputSchema: withUserAuth(z.object({
method: z
.enum(['POST', 'PUT', 'PATCH', 'DELETE'])
.describe('HTTP method. DELETE is destructive and not reversible.'),
path: z
.string()
.min(1)
.describe('Path under /api/v4, beginning with "/" (e.g. "/projects/42/issues" or "/projects/foo%2Fbar/merge_requests/3/merge"). Must not include host, "/api/v4" prefix, or "?" query string.'),
body: z
.any()
.optional()
.describe('Request body — JSON-serialized as application/json. Omit for endpoints that don\'t take a body (most DELETE / some PUT).'),
query: z
.record(z.union([z.string(), z.number(), z.boolean()]))
.optional()
.describe('Query string parameters. Most write endpoints take their args in the body, but a few mix.'),
})),
handler: async (input, client, userConfig) => {
const credentials = input.userCredentials ? validateUserConfig(input.userCredentials) : userConfig;
return client.executeRestWrite(input.method, input.path, { body: input.body, query: input.query }, credentials);
},
};

const getAvailableQueriesTools: Tool = {
name: 'get_available_queries',
title: 'Available Queries',
Expand Down Expand Up @@ -2406,6 +2461,7 @@ export const readOnlyTools: Tool[] = [
getIssuesTool,
getMergeRequestsTool,
executeCustomQueryTool,
executeRestReadTool,
getAvailableQueriesTools,
getMergeRequestPipelinesTool,
getPipelineJobsTool,
Expand Down Expand Up @@ -2437,6 +2493,7 @@ export const userAuthTools: Tool[] = [
];

export const writeTools: Tool[] = [
executeRestWriteTool,
createIssueTool,
createMergeRequestTool,
createNoteTool,
Expand Down
Loading