From d4ccb430d92cdb7e5974f4209c1091c33d23f8a9 Mon Sep 17 00:00:00 2001 From: Tim Pearson Date: Thu, 21 May 2026 23:24:37 -0400 Subject: [PATCH] feat: execute_rest_read / execute_rest_write tools (1.19.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds open-ended REST escape hatches matching the existing execute_custom_query for GraphQL. Lets agents reach any /api/v4 endpoint not covered by a dedicated tool instead of waiting for a curated tool PR. Split into two tools so the read variant is readOnlyHint: true / requiresWrite: false and the write variant is requiresWrite: true / destructiveHint: true. Same shape as the GITLAB_TOKEN / GITLAB_READ_TOKEN separation — the read tool works with a read-only token, the write tool is rejected by getClient when only a read token is configured. Lets the MCP harness gate destructive calls without asking the model to self-declare intent. Path is validated to be /api/v4-relative before the request runs: must start with '/', no host, no '?' query string (use the query arg), no '..' segments. Prevents the caller from escaping the configured GitLab base URL. Also: - restRequest now accepts PATCH (some endpoints use it, e.g. some MR approval-rule routes) - Cleaned up a no-op startsWith('gid://') ternary in markAllTodosDone whose two branches resolved to the same value --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 9 ++++++ package-lock.json | 4 +-- package.json | 2 +- src/gitlab-client.ts | 60 ++++++++++++++++++++++++++++++++++++-- src/tools.ts | 57 ++++++++++++++++++++++++++++++++++++ 6 files changed, 128 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ea90799..412da51 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index cedb729..56b4329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 8e8cfa4..e620610 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ttpears/gitlab-mcp-server", - "version": "1.18.1", + "version": "1.19.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ttpears/gitlab-mcp-server", - "version": "1.18.1", + "version": "1.19.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/package.json b/package.json index 5bfbf8f..91361fe 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/gitlab-client.ts b/src/gitlab-client.ts index 1888eb0..38b52c4 100644 --- a/src/gitlab-client.ts +++ b/src/gitlab-client.ts @@ -2889,7 +2889,7 @@ export class GitLabGraphQLClient { ): Promise<{ todos: Array<{ id: string; state: string }>; errors: string[] }> { const input: Record = {}; 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 => @@ -2993,7 +2993,7 @@ export class GitLabGraphQLClient { * (e.g., broadcast messages). */ private async restRequest( - method: 'GET' | 'POST' | 'PUT' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', path: string, options: { body?: any; query?: Record; userConfig?: UserConfig; requiresWrite?: boolean } = {} ): Promise { @@ -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( + path: string, + query?: Record, + userConfig?: UserConfig, + ): Promise { + return this.restRequest('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( + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE', + path: string, + options: { body?: any; query?: Record } = {}, + userConfig?: UserConfig, + ): Promise { + return this.restRequest(method, this.validateRestPath(path), { + body: options.body, + query: options.query, + userConfig, + requiresWrite: true, + }); + } + async listBroadcastMessages(page = 1, perPage = 20, userConfig?: UserConfig): Promise { return this.restRequest('GET', '/broadcast_messages', { query: { page, per_page: Math.min(perPage, this.config.maxPageSize) }, diff --git a/src/tools.ts b/src/tools.ts index 0748bab..ab25d0c 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -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', @@ -2406,6 +2461,7 @@ export const readOnlyTools: Tool[] = [ getIssuesTool, getMergeRequestsTool, executeCustomQueryTool, + executeRestReadTool, getAvailableQueriesTools, getMergeRequestPipelinesTool, getPipelineJobsTool, @@ -2437,6 +2493,7 @@ export const userAuthTools: Tool[] = [ ]; export const writeTools: Tool[] = [ + executeRestWriteTool, createIssueTool, createMergeRequestTool, createNoteTool,