diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..be2f2c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +** +!package.json +!package-lock.json diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..e76ffb9 --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,61 @@ +name: Validate + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + container: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci --ignore-scripts + + - name: Check scripts + run: | + node --check scripts/generate-tools-json.mjs + node --check scripts/check-tools-json.mjs + node --check scripts/smoke-mcp.mjs + + - name: Build image + run: docker build -t gitkraken-mcp:test . + + - name: Check catalog tool-list drift + env: + TOOLS_COMMAND: '["docker","run","--rm","gitkraken-mcp:test","--list-tools"]' + run: npm run check:tools + + - name: Smoke test MCP and mounted repository access + run: | + repository="$(mktemp -d)" + git init "$repository" + export repository + MCP_COMMAND="$(node -e 'console.log(JSON.stringify(["docker", "run", "--rm", "-i", "--mount", `type=bind,source=${process.env.repository},target=/workspace/repo`, "gitkraken-mcp:test"]))')" + MCP_COMMAND="$MCP_COMMAND" MCP_SMOKE_REPOSITORY=/workspace/repo npm run smoke:mcp + + multiarch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-qemu-action@v3 + + - uses: docker/setup-buildx-action@v3 + + - name: Build Linux architectures + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9dd1858 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim + +RUN apt-get update \ + && apt-get install --no-install-recommends -y ca-certificates git \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /app /workspace \ + && chown node:node /app /workspace + +WORKDIR /app + +COPY --chown=node:node package.json package-lock.json ./ + +USER node + +RUN --mount=type=cache,target=/home/node/.npm,uid=1000,gid=1000 \ + npm ci --omit=dev + +RUN /app/node_modules/.bin/gk mcp --list-tools --no-telemetry > /dev/null + +RUN git config --global --add safe.directory '*' + +WORKDIR /workspace + +LABEL io.modelcontextprotocol.server.name="com.gitkraken/gk-cli" + +ENTRYPOINT ["/app/node_modules/.bin/gk", "mcp"] diff --git a/README.md b/README.md index 8436c2c..489f7d1 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ If you want to read more about the MCP server, you can check out the [introducto - [Tools](#tools) - [Prompts](#prompts) - [Installation](#installation) +- [Docker](#docker) - [Troubleshooting](#troubleshooting) - [Support](#support) @@ -18,6 +19,16 @@ If you want to read more about the MCP server, you can check out the [introducto Tools are the primary purpose of the MCP server. They are a set of finely curated commands that AI can use to interact with GitKraken without exploding your context. Some of those tools include: `issues_assigned_to_me`, `gitlens_commit_composer`, and `pull_request_create_review`. A full list of tools can be found in the GitKraken Help Center's [Tools Reference](https://help.gitkraken.com/mcp/mcp-tools-reference/). +The repository also includes a Docker MCP Catalog-compatible [`tools.json`](tools.json). To refresh it from an installed GitKraken CLI, run: + +```bash +node scripts/generate-tools-json.mjs +``` + +Set `GK_BIN` when `gk` is not on `PATH`, or set `TOOLS_COMMAND` to a JSON +command array when generating from a container. The generator intentionally +excludes app-only tools that MCP agents must not call. + ## Prompts Prompts are the secondary purpose of the MCP server. They are a set of carefully crafted instructions that AI can use to understand how to use the tools, when to use them, and what information to provide when using them. A full list of prompts can be found in the GitKraken Help Center's [Prompts Reference](https://help.gitkraken.com/mcp/mcp-prompts-reference/). @@ -48,6 +59,43 @@ The installation process details may vary by AI tool, but the general gist is th } ``` +## Docker + +This repository includes auth-independent Docker packaging for the local MCP +server. The image can list tools and run local Git tools against explicitly +mounted repositories. Cloud workspaces, issues, pull requests, and provider +operations still require a supported non-interactive GitKraken authentication +contract. + +Build the image: + +```bash +docker build -t gitkraken-mcp:local . +``` + +List the tools without authenticating: + +```bash +docker run --rm gitkraken-mcp:local --list-tools +``` + +Check `tools.json` against the clean image: + +```bash +npm install +TOOLS_COMMAND='["docker","run","--rm","gitkraken-mcp:local","--list-tools"]' npm run check:tools +``` + +The image runs as a non-root user and starts `gk mcp` over stdio. When using it +through Docker MCP Toolkit, only mount repository paths that the server should +be allowed to read and modify. Linked Git worktrees also require access to the +external Git directory referenced by their `.git` file. A draft registry entry +is available in +[`docker/catalog/server.yaml.template`](docker/catalog/server.yaml.template). +Copy that template and `tools.json` into `servers/gitkraken/` in a checkout of +[`docker/mcp-registry`](https://github.com/docker/mcp-registry) when preparing +the catalog pull request. + ### CLI #### macOS diff --git a/docker/catalog/README.md b/docker/catalog/README.md new file mode 100644 index 0000000..18387a1 --- /dev/null +++ b/docker/catalog/README.md @@ -0,0 +1,21 @@ +# Docker MCP Catalog submission + +This directory contains the source template for the GitKraken entry in +[`docker/mcp-registry`](https://github.com/docker/mcp-registry). + +To prepare a registry pull request: + +1. Copy `server.yaml.template` to `servers/gitkraken/server.yaml` in a checkout + of `docker/mcp-registry`. +2. Replace `` with the full commit SHA containing the + Dockerfile being submitted. +3. Copy the repository root `tools.json` next to `server.yaml`. +4. Add the approved GitKraken authentication configuration once its + non-interactive container contract is finalized. +5. Confirm that the license declared by the distributed `@gitkraken/gk` + package is acceptable for Docker Catalog redistribution. +6. Run the registry's `task build -- --tools gitkraken` and + `task catalog -- gitkraken` checks before opening the pull request. + +The `paths` parameter is intentionally required because the local Git tools +must only receive access to repositories explicitly selected by the user. diff --git a/docker/catalog/server.yaml.template b/docker/catalog/server.yaml.template new file mode 100644 index 0000000..71bafc2 --- /dev/null +++ b/docker/catalog/server.yaml.template @@ -0,0 +1,33 @@ +name: gitkraken +image: mcp/gitkraken +type: server +meta: + category: devops + tags: + - git + - devops + - issues + - pull-requests +about: + title: GitKraken + description: Work with Git repositories, issues, and pull requests through GitKraken. + icon: https://avatars.githubusercontent.com/u/92606490?v=4 +source: + project: https://github.com/gitkraken/mcp + commit: +run: + volumes: + - "{{gitkraken.paths|volume|into}}" +config: + description: Configure the local repository paths GitKraken is allowed to access. + parameters: + type: object + properties: + paths: + type: array + items: + type: string + default: + - /Users/local-test + required: + - paths diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c9f0d25 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,539 @@ +{ + "name": "gitkraken-mcp-catalog", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitkraken-mcp-catalog", + "version": "0.0.0", + "dependencies": { + "@gitkraken/gk": "3.1.70" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@gitkraken/gk": { + "version": "3.1.70", + "resolved": "https://registry.npmjs.org/@gitkraken/gk/-/gk-3.1.70.tgz", + "integrity": "sha512-ZWef75qI7Y2zzghDygGrECuZItSEXOWje8HX0sk9qlq6scgAlgswhlS6ve/y1PpyH8lM5R2T2L2ZpxLoBp6xnA==", + "hasInstallScript": true, + "license": "CC-BY-3.0-US", + "dependencies": { + "jszip": "3.10.1", + "proxy-agent": "8.0.2", + "tar": "7.5.19" + }, + "bin": { + "gk": "run-gk.js" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/basic-ftp": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", + "integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-8.0.0.tgz", + "integrity": "sha512-6UHfyCux51b8PTGDgveqtz1tvphBku5DrMKKJbFAZAJOI2zsjDpDoYE1+QGj7FOMS4BdTFNJsJiR3zEB0xH0yQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/degenerator": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-7.0.1.tgz", + "integrity": "sha512-ABErK0IefDSyHjlPH7WUEenIAX2rPPnrDcDM+TS3z3+zu9TfyKKi07BQM+8rmxpdE2y1v5fjjdoAS/x4D2U60w==", + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "quickjs-wasi": "^2.2.0" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "license": "BSD-2-Clause", + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/get-uri": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.1.tgz", + "integrity": "sha512-/5N/P4Lrh0p/mDwlDRi7Y1+P2o/OyzZI3l6Iz1Ov6XXwwm1y3RlZLuo3gVgML99djrEDtV980bBxSuOeHLk8ww==", + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.3.1", + "data-uri-to-buffer": "8.0.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/netmask": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.1.1.tgz", + "integrity": "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/pac-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-9.1.0.tgz", + "integrity": "sha512-1aU+1mpj3DrQPfo3gh+3Gap3G5x+axnMx1P/y0ZF2ch7kb2meyOCAH8K2k9d27ROsTE7TnAerzxqF9aon2jqnA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "get-uri": "8.0.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "pac-resolver": "9.0.1", + "quickjs-wasi": "^2.2.0", + "socks-proxy-agent": "10.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/pac-resolver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-9.0.1.tgz", + "integrity": "sha512-lJbS008tmkj08VhoM8Hzuv/VE5tK9MS0OIQ/7+s0lIF+BYhiQWFYzkSpML7lXs9iBu2jfmzBTLzhe9n6BX+dYw==", + "license": "MIT", + "dependencies": { + "degenerator": "7.0.1", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "quickjs-wasi": "^2.2.0" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-agent": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-8.0.2.tgz", + "integrity": "sha512-idLLRewuemWd7GH/BDJzGiB0dWGfT2SQs3jy6NtZtGWU9uPTTSdeC1/cdbqLwgzhfv027daGFuXX426e2Eg20A==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "9.1.0", + "proxy-from-env": "^2.0.0", + "socks-proxy-agent": "10.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quickjs-wasi": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-2.2.0.tgz", + "integrity": "sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz", + "integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tar": { + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..f7c88d5 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "gitkraken-mcp-catalog", + "version": "0.0.0", + "private": true, + "description": "Docker MCP Catalog packaging and validation for the GitKraken MCP server", + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "generate:tools": "node scripts/generate-tools-json.mjs", + "check:tools": "node scripts/check-tools-json.mjs", + "smoke:mcp": "node scripts/smoke-mcp.mjs" + }, + "dependencies": { + "@gitkraken/gk": "3.1.70" + } +} diff --git a/scripts/check-tools-json.mjs b/scripts/check-tools-json.mjs new file mode 100644 index 0000000..85feb2b --- /dev/null +++ b/scripts/check-tools-json.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)); +const repositoryRoot = resolve(scriptDirectory, ".."); +const generatorPath = join(scriptDirectory, "generate-tools-json.mjs"); +const committedPath = join(repositoryRoot, "tools.json"); +const temporaryDirectory = mkdtempSync(join(tmpdir(), "gk-mcp-tools-")); +const generatedPath = join(temporaryDirectory, "tools.json"); + +try { + const result = spawnSync(process.execPath, [generatorPath, generatedPath], { + cwd: repositoryRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + }); + + if (result.error) { + throw new Error(`Unable to regenerate tools.json: ${result.error.message}`); + } + + if (result.status !== 0) { + const details = [result.stdout, result.stderr] + .filter(Boolean) + .join("\n") + .trim(); + throw new Error( + `The tools.json generator exited with status ${result.status}.${ + details ? `\n${details}` : "" + }`, + ); + } + + const committed = readFileSync(committedPath, "utf8"); + const generated = readFileSync(generatedPath, "utf8"); + + if (committed !== generated) { + const committedLines = committed.split("\n"); + const generatedLines = generated.split("\n"); + const differingLine = generatedLines.findIndex( + (line, index) => line !== committedLines[index], + ); + const location = differingLine === -1 ? "at end of file" : `at line ${differingLine + 1}`; + + throw new Error( + `tools.json is out of date (${location}). Run ` + + "`node scripts/generate-tools-json.mjs` and commit the result.", + ); + } + + console.log("tools.json matches the current GitKraken MCP tool list."); +} catch (error) { + console.error(`check-tools-json: ${error.message}`); + process.exitCode = 1; +} finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/scripts/generate-tools-json.mjs b/scripts/generate-tools-json.mjs new file mode 100644 index 0000000..bf52b5c --- /dev/null +++ b/scripts/generate-tools-json.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const outputPath = resolve(process.argv[2] ?? "tools.json"); +const command = parseCommand(); +const deniedToolNames = new Set(["app_tool_box", "app_update_user_preferences"]); + +const output = execFileSync(command[0], command.slice(1), { + encoding: "utf8", + stdio: ["ignore", "pipe", "inherit"], +}); + +const tools = []; +let currentTool; +let currentArgument; + +function finishArgument() { + currentArgument = undefined; +} + +function finishTool() { + finishArgument(); + currentTool = undefined; +} + +for (const line of output.split(/\r?\n/)) { + const toolMatch = line.match(/^Tool: (.+)$/); + if (toolMatch) { + finishTool(); + if (deniedToolNames.has(toolMatch[1])) { + continue; + } + currentTool = { + name: toolMatch[1], + description: "", + arguments: [], + }; + tools.push(currentTool); + continue; + } + + if (!currentTool) { + continue; + } + + const descriptionMatch = line.match(/^ Description:\s*(.+)$/); + if (descriptionMatch) { + currentTool.description = descriptionMatch[1].trim(); + continue; + } + + const argumentMatch = line.match(/^ - ([^\s]+) \[([^\]]+)\](?: \(required\))?$/); + if (argumentMatch) { + currentArgument = { + name: argumentMatch[1], + type: argumentMatch[2], + desc: "", + }; + currentTool.arguments.push(currentArgument); + continue; + } + + const argumentDescriptionMatch = line.match(/^ (.+)$/); + if (argumentDescriptionMatch && currentArgument) { + currentArgument.desc = currentArgument.desc + ? `${currentArgument.desc} ${argumentDescriptionMatch[1].trim()}` + : argumentDescriptionMatch[1].trim(); + } +} + +if (tools.length === 0) { + throw new Error("No MCP tools were found in `gk mcp --list-tools` output."); +} + +writeFileSync(outputPath, `${JSON.stringify(tools, null, 2)}\n`); +console.log(`Wrote ${tools.length} tools to ${outputPath}`); + +function parseCommand() { + if (process.env.TOOLS_COMMAND) { + const parsed = JSON.parse(process.env.TOOLS_COMMAND); + if ( + !Array.isArray(parsed) || + parsed.length === 0 || + parsed.some((argument) => typeof argument !== "string" || argument.length === 0) + ) { + throw new Error("TOOLS_COMMAND must be a JSON array of nonempty strings."); + } + return parsed; + } + + return [process.env.GK_BIN ?? "gk", "mcp", "--list-tools"]; +} diff --git a/scripts/smoke-mcp.mjs b/scripts/smoke-mcp.mjs new file mode 100644 index 0000000..fe064d4 --- /dev/null +++ b/scripts/smoke-mcp.mjs @@ -0,0 +1,328 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; + +let child; +let exit; + +try { + const requestTimeoutMs = parseTimeout("MCP_TIMEOUT_MS", 15_000); + const shutdownTimeoutMs = parseTimeout("MCP_SHUTDOWN_TIMEOUT_MS", 5_000); + const command = parseCommand(process.env.MCP_COMMAND); + + child = spawn(command.file, command.shell ? [] : command.args.slice(1), { + env: process.env, + shell: command.shell, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk) => { + stderr = `${stderr}${chunk}`.slice(-16_384); + }); + + exit = waitForExit(child); + const rpc = createJsonRpcClient(child, requestTimeoutMs, () => stderr); + + const initialize = await rpc.request("initialize", { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { + name: "gk-mcp-smoke-test", + version: "1.0.0", + }, + }); + + assertObject(initialize, "initialize result"); + if (typeof initialize.protocolVersion !== "string") { + throw new Error("initialize result is missing a string protocolVersion"); + } + + rpc.notify("notifications/initialized"); + + const listed = await rpc.request("tools/list", {}); + const tools = validateToolsResult(listed); + + if (process.env.MCP_SMOKE_REPOSITORY !== undefined) { + if (!tools.some((tool) => tool.name === "git_status")) { + throw new Error("tools/list did not advertise the required git_status tool"); + } + + const callResult = await rpc.request("tools/call", { + name: "git_status", + arguments: { directory: process.env.MCP_SMOKE_REPOSITORY }, + }); + assertObject(callResult, "git_status result"); + if (callResult.isError === true) { + throw new Error(`git_status returned an MCP error: ${formatToolContent(callResult.content)}`); + } + } + + child.stdin.end(); + const outcome = await withTimeout( + exit, + shutdownTimeoutMs, + `MCP server did not exit within ${shutdownTimeoutMs}ms after stdin closed`, + ); + assertSuccessfulExit(outcome, stderr); + + console.log(`MCP smoke test passed: ${tools.length} tools.`); +} catch (error) { + await stopChild(child, exit); + console.error(`smoke-mcp: ${error.message}`); + process.exitCode = 1; +} + +function parseCommand(value) { + if (!value?.trim()) { + return { file: "gk", args: ["gk", "mcp"], shell: false }; + } + + const trimmed = value.trim(); + if (trimmed.startsWith("[")) { + let args; + try { + args = JSON.parse(trimmed); + } catch (error) { + throw new Error(`MCP_COMMAND is not valid JSON: ${error.message}`); + } + if ( + !Array.isArray(args) || + args.length === 0 || + args.some((argument) => typeof argument !== "string" || argument.length === 0) + ) { + throw new Error("MCP_COMMAND JSON must be a nonempty array of nonempty strings"); + } + return { file: args[0], args, shell: false }; + } + + return { file: trimmed, args: [], shell: true }; +} + +function parseTimeout(name, fallback) { + const value = process.env[name]; + if (value === undefined) return fallback; + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive number of milliseconds`); + } + return parsed; +} + +function createJsonRpcClient(process, timeoutMs, getStderr) { + let nextId = 1; + let stdoutBuffer = ""; + const pending = new Map(); + + process.stdout.setEncoding("utf8"); + process.stdout.on("data", (chunk) => { + stdoutBuffer += chunk; + let newline; + while ((newline = stdoutBuffer.indexOf("\n")) !== -1) { + const line = stdoutBuffer.slice(0, newline).replace(/\r$/, ""); + stdoutBuffer = stdoutBuffer.slice(newline + 1); + if (line.trim()) handleLine(line); + } + }); + + process.stdout.on("end", () => { + if (stdoutBuffer.trim()) handleLine(stdoutBuffer.replace(/\r$/, "")); + }); + + function handleLine(line) { + let message; + try { + message = JSON.parse(line); + } catch (error) { + rejectAll(new Error(`MCP server wrote invalid JSON to stdout: ${error.message}\n${line}`)); + return; + } + + if (message?.jsonrpc !== "2.0" || !("id" in message)) return; + const request = pending.get(message.id); + if (!request) return; + + clearTimeout(request.timer); + pending.delete(message.id); + if (message.error) { + request.reject( + new Error( + `JSON-RPC request ${request.method} failed: ${JSON.stringify(message.error)}`, + ), + ); + } else if (!("result" in message)) { + request.reject(new Error(`JSON-RPC response to ${request.method} has no result`)); + } else { + request.resolve(message.result); + } + } + + function write(message) { + if (!process.stdin.writable) { + throw new Error("MCP server stdin is not writable"); + } + process.stdin.write(`${JSON.stringify(message)}\n`); + } + + function rejectAll(error) { + for (const request of pending.values()) { + clearTimeout(request.timer); + request.reject(error); + } + pending.clear(); + } + + process.once("error", (error) => { + rejectAll(new Error(`Unable to launch MCP server: ${error.message}`)); + }); + process.stdin.on("error", (error) => { + rejectAll(new Error(`Unable to write to MCP server stdin: ${error.message}`)); + }); + process.once("exit", (code, signal) => { + const details = formatStderr(getStderr()); + rejectAll( + new Error( + `MCP server exited before completing requests (${formatExit(code, signal)})${details}`, + ), + ); + }); + + return { + request(method, params) { + const id = nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject( + new Error( + `JSON-RPC request ${method} timed out after ${timeoutMs}ms${formatStderr( + getStderr(), + )}`, + ), + ); + }, timeoutMs); + pending.set(id, { method, resolve, reject, timer }); + try { + write({ jsonrpc: "2.0", id, method, params }); + } catch (error) { + clearTimeout(timer); + pending.delete(id); + reject(error); + } + }); + }, + notify(method, params) { + write({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) }); + }, + }; +} + +function validateToolsResult(result) { + assertObject(result, "tools/list result"); + if (!Array.isArray(result.tools) || result.tools.length === 0) { + throw new Error("tools/list result must contain a nonempty tools array"); + } + + const names = new Set(); + for (const [index, tool] of result.tools.entries()) { + assertObject(tool, `tool at index ${index}`); + if (typeof tool.name !== "string" || tool.name.trim() === "") { + throw new Error(`tool at index ${index} has an invalid name`); + } + if (names.has(tool.name)) { + throw new Error(`tools/list returned duplicate tool name ${JSON.stringify(tool.name)}`); + } + names.add(tool.name); + + if (tool.description !== undefined && typeof tool.description !== "string") { + throw new Error(`tool ${JSON.stringify(tool.name)} has an invalid description`); + } + assertObject(tool.inputSchema, `inputSchema for tool ${JSON.stringify(tool.name)}`); + if (tool.inputSchema.type !== "object") { + throw new Error(`inputSchema for tool ${JSON.stringify(tool.name)} must have type object`); + } + } + + return result.tools; +} + +function assertObject(value, label) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } +} + +function waitForExit(process) { + return new Promise((resolve) => { + process.once("error", (error) => { + resolve({ error }); + }); + process.once("exit", (code, signal) => { + resolve({ code, signal }); + }); + }); +} + +function assertSuccessfulExit(outcome, stderr) { + if (outcome.error) { + throw new Error(`Unable to launch MCP server: ${outcome.error.message}`); + } + if (outcome.code !== 0) { + throw new Error( + `MCP server ${formatExit(outcome.code, outcome.signal)}${formatStderr(stderr)}`, + ); + } +} + +async function stopChild(process, exit) { + if (!process || process.exitCode !== null || process.signalCode !== null) return; + + process.kill("SIGTERM"); + if (exit) { + const stopped = await Promise.race([ + exit.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 1_000)), + ]); + if (stopped) return; + } + + process.kill("SIGKILL"); +} + +function withTimeout(promise, milliseconds, message) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(message)), milliseconds); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error) => { + clearTimeout(timer); + reject(error); + }, + ); + }); +} + +function formatExit(code, signal) { + return signal ? `was terminated by ${signal}` : `exited with status ${code}`; +} + +function formatStderr(stderr) { + const trimmed = stderr.trim(); + return trimmed ? `\nServer stderr:\n${trimmed}` : ""; +} + +function formatToolContent(content) { + if (!Array.isArray(content)) return JSON.stringify(content); + + const text = content + .filter((item) => item && typeof item.text === "string") + .map((item) => item.text) + .join("\n") + .trim(); + return text || JSON.stringify(content); +} diff --git a/tools.json b/tools.json new file mode 100644 index 0000000..4f79ea1 --- /dev/null +++ b/tools.json @@ -0,0 +1,764 @@ +[ + { + "name": "git_add_or_commit", + "description": "Add file contents to the index (git add ) OR record changes to the repository (git commit -m [files...]). Use the 'action' parameter to specify which action to perform.", + "arguments": [ + { + "name": "action", + "type": "string", + "desc": "The action to perform: 'add' or 'commit'" + }, + { + "name": "description", + "type": "string", + "desc": "Optional commit description/body (only applies when action is 'commit')" + }, + { + "name": "directory", + "type": "string", + "desc": "The directory to run git add or commit in" + }, + { + "name": "files", + "type": "array", + "desc": "Optional array of files to add or commit. If omitted, all files are added or all staged changes are committed." + }, + { + "name": "message", + "type": "string", + "desc": "The commit message (required if action is 'commit')" + } + ] + }, + { + "name": "git_blame", + "description": "Show what revision and author last modified each line of a file (git blame ).", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "The directory to run git blame in" + }, + { + "name": "file", + "type": "string", + "desc": "The file to blame" + } + ] + }, + { + "name": "git_branch", + "description": "List or create branches (git branch).", + "arguments": [ + { + "name": "action", + "type": "string", + "desc": "Git branch action to be executed" + }, + { + "name": "branch_name", + "type": "string", + "desc": "(Optional) Name of the branch to create or delete" + }, + { + "name": "directory", + "type": "string", + "desc": "The directory to run git branch in" + } + ] + }, + { + "name": "git_checkout", + "description": "Switch branches or restore working tree files (git checkout ).", + "arguments": [ + { + "name": "branch", + "type": "string", + "desc": "The branch to checkout. This must be a valid branch name without spaces" + }, + { + "name": "directory", + "type": "string", + "desc": "The directory to run git checkout in" + } + ] + }, + { + "name": "git_commit_composer", + "description": "Show the commit composer for a repository, will open a UI if client supports it.", + "arguments": [ + { + "name": "custom_instructions", + "type": "string", + "desc": "Free-form guidance for the AI commit composer. Only meaningful when 'direction' is 'custom'." + }, + { + "name": "direction", + "type": "string", + "desc": "Initial grouping preset for the composer. 'auto' lets the AI choose; 'area' groups by codebase area / subsystem; 'type' groups by conventional commit type; 'custom' uses the supplied 'custom_instructions' text. Defaults to 'auto'." + }, + { + "name": "directory", + "type": "string", + "desc": "Path of the working directory." + } + ] + }, + { + "name": "git_fetch", + "description": "Download objects and refs from another repository (git fetch).", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "The directory to run git fetch in" + } + ] + }, + { + "name": "git_graph", + "description": "Show the commit graph for a repository (git log --graph), will open a UI if client supports it.", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "Path of the working directory." + } + ] + }, + { + "name": "git_log_or_diff", + "description": "Show commit logs or changes between commits (git log --oneline or git diff).", + "arguments": [ + { + "name": "action", + "type": "string", + "desc": "The action to perform: 'log' for commit logs or 'diff' for changes" + }, + { + "name": "authors", + "type": "array", + "desc": "Optional array of author names or emails to filter commits by (only applies to 'log' action)" + }, + { + "name": "directory", + "type": "string", + "desc": "The directory to run the command in" + }, + { + "name": "revision_range", + "type": "string", + "desc": "Optional revision range (e.g., 'HEAD', 'main..feature', 'abc123', 'HEAD~5..HEAD'). For 'diff' action, shows differences for the specified range. For 'log' action, shows commits in the range. Defaults to HEAD." + }, + { + "name": "since", + "type": "string", + "desc": "Optional date/timestamp/relative time to show commits after (e.g., '2024-01-01', '2 weeks ago', 'yesterday', '1 hour ago', '3 days ago'). Only applies to 'log' action." + }, + { + "name": "until", + "type": "string", + "desc": "Optional date/timestamp/relative time to show commits before (e.g., '2024-12-31', '1 week ago', 'today', 'yesterday', '2 hours ago'). Only applies to 'log' action." + } + ] + }, + { + "name": "git_pull", + "description": "Fetch from and integrate with another repository or a local branch (git pull).", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "The directory to run git pull in" + } + ] + }, + { + "name": "git_push", + "description": "Update remote refs along with associated objects (git push).", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "The directory to run git push in" + } + ] + }, + { + "name": "git_resolve", + "description": "Show the conflict resolution UI for a repository, will open a UI if client supports it.", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "Path of the working directory." + }, + { + "name": "instructions", + "type": "string", + "desc": "Optional guidance for the AI conflict resolution flow." + } + ] + }, + { + "name": "git_stash", + "description": "Stash the changes in a dirty working directory (git stash).", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "The directory to run git stash in" + }, + { + "name": "include_untracked", + "type": "boolean", + "desc": "When true, include untracked files in the stash." + }, + { + "name": "name", + "type": "string", + "desc": "Optional name for the stash (used as the stash message)" + }, + { + "name": "staged_only", + "type": "boolean", + "desc": "When true, stash only the currently staged changes and leave unstaged work untouched." + } + ] + }, + { + "name": "git_status", + "description": "Show the working tree status (git status), will open a UI if client supports it.", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "Path of the working directory." + } + ] + }, + { + "name": "git_worktree", + "description": "List or add git worktrees (git worktree ).", + "arguments": [ + { + "name": "action", + "type": "string", + "desc": "Git worktree action to be executed" + }, + { + "name": "branch", + "type": "string", + "desc": "(Optional) Existing branch for the new worktree (used for add)" + }, + { + "name": "directory", + "type": "string", + "desc": "The directory to run git worktree in" + }, + { + "name": "path", + "type": "string", + "desc": "(Optional) Path for the worktree (required for add)" + } + ] + }, + { + "name": "gitkraken_workspace_list", + "description": "Lists all Gitkraken workspaces", + "arguments": [] + }, + { + "name": "gitlens_launchpad", + "description": "Gitlens Launchpad. Gets your open pull requests prioritized by what needs attention: ready to merge, has conflicts, awaiting review, etc. Helpful for checking todos, outstanding tasks, or deciding what to work on next.", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "Path of the working directory." + } + ] + }, + { + "name": "gitlens_start_review", + "description": "Gitlens Start Review. Creates a dedicated worktree and reviews your PR with an AI agent.", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "Path of the working directory." + }, + { + "name": "instructions", + "type": "string", + "desc": "OPTIONAL. Use this ONLY if the user explicitly provided specific requirements about the review focus, review criteria, or what aspects to check. Do NOT use this parameter unless you are 100% certain about the user's intentions. Examples: 'focus on security issues', 'check for performance regressions', 'review only the authentication logic'." + }, + { + "name": "pr_url", + "type": "string", + "desc": "URL of the PR to start review." + } + ] + }, + { + "name": "gitlens_start_work", + "description": "Gitlens Start Work. Creates a work based on an issue. This tool will create a branch and link it with the issue, keeping context visible throughout your work.", + "arguments": [ + { + "name": "directory", + "type": "string", + "desc": "Path of the working directory." + }, + { + "name": "instructions", + "type": "string", + "desc": "OPTIONAL. Use this ONLY if the user explicitly provided specific requirements about the implementation approach, or additional context to supplement the issue. Do NOT use this parameter unless you are 100% certain about the user's intentions." + }, + { + "name": "issue_url", + "type": "string", + "desc": "URL of the issue to start work on." + } + ] + }, + { + "name": "issues_add_comment", + "description": "Add a comment to an issue", + "arguments": [ + { + "name": "azure_organization", + "type": "string", + "desc": "Optionally set the Azure DevOps organization name. Required for Azure DevOps" + }, + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name. Required for Azure DevOps" + }, + { + "name": "comment", + "type": "string", + "desc": "The text content of the comment" + }, + { + "name": "issue_id", + "type": "string", + "desc": "The ID of the issue to comment on" + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the issue provider" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Repository name. This is required for GitHub and GitLab" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Organization name. This is required for GitHub and GitLab" + } + ] + }, + { + "name": "issues_assigned_to_me", + "description": "Fetch issues assigned to the user. Set assigned_to_me=false to broaden the result to all visible issues (so the caller can then filter client-side). Narrow results server-side with repository_name/repository_organization (GitHub), project/issue_type (Jira), or is_closed.", + "arguments": [ + { + "name": "assigned_to_me", + "type": "boolean", + "desc": "Whether to limit results to issues assigned to you. Defaults to true." + }, + { + "name": "azure_organization", + "type": "string", + "desc": "Optionally set the Azure DevOps organization name. Required for Azure DevOps" + }, + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name. Required for Azure DevOps" + }, + { + "name": "fields", + "type": "string", + "desc": "Optional comma-separated list of top-level fields to keep on each result. Recommended for list/overview calls: omitting it returns every field, which on large lists can overflow the context window. Omit only when you need the full objects. Available fields: id, url, number, provider, repoName, repoOwner, repoHost, project, title, description, issueType, status, assignees, comments, childIssues." + }, + { + "name": "is_closed", + "type": "boolean", + "desc": "Set to true to also include closed/done issues. Defaults to false." + }, + { + "name": "issue_type", + "type": "string", + "desc": "Issue type. Jira only (e.g. Bug, Story, Task)." + }, + { + "name": "page", + "type": "number", + "desc": "Optional parameter to specify the page number, defaults to 1" + }, + { + "name": "project", + "type": "string", + "desc": "Project key. Jira only (e.g. GKDEV)." + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the issue provider" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Repository name. GitHub only; must be combined with repository_organization." + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Organization name. GitHub only." + } + ] + }, + { + "name": "issues_create", + "description": "Create a new issue. For Jira, repository_name is the project key. For Linear, repository_organization is the team UUID, key, or name.", + "arguments": [ + { + "name": "assignees", + "type": "array", + "desc": "Optional list of assignees. GitHub: usernames; GitLab: user IDs; Jira/Linear/Azure: first entry is used" + }, + { + "name": "azure_organization", + "type": "string", + "desc": "Optionally set the Azure DevOps organization name. Required for Azure DevOps" + }, + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name. Required for Azure DevOps" + }, + { + "name": "body", + "type": "string", + "desc": "The body/description of the issue (markdown supported where the provider allows)" + }, + { + "name": "labels", + "type": "array", + "desc": "Optional list of label names to apply" + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the issue provider" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Repository name. Required for GitHub/GitLab; for Jira this is the project key" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Organization name. Required for GitHub/GitLab; for Linear use as team identifier" + }, + { + "name": "title", + "type": "string", + "desc": "The title of the issue" + } + ] + }, + { + "name": "issues_get_detail", + "description": "Retrieve detailed information about a specific issue by its unique ID. For Jira Epics, the response includes a childIssues array containing all issues linked to the epic.", + "arguments": [ + { + "name": "azure_organization", + "type": "string", + "desc": "Optionally set the Azure DevOps organization name. Required for Azure DevOps" + }, + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name. Required for Azure DevOps" + }, + { + "name": "issue_id", + "type": "string", + "desc": "The Number or ID of the issue to retrieve. Supported formats include GitHub/GitLab numeric IDs (e.g., 123 or #123), Jira keys (e.g., PROJ-123), Linear issue IDs (UUID format), etc." + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the issue provider" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Repository name. This is required for GitHub and GitLab" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Organization name. This is required for GitHub and GitLab" + } + ] + }, + { + "name": "pull_request_assigned_to_me", + "description": "Search pull requests where you are the author or assignee. Set reviewer to true to also include pull requests where you are a requested reviewer (github and gitlab only).", + "arguments": [ + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name of the pull request. Required for Azure DevOps" + }, + { + "name": "fields", + "type": "string", + "desc": "Optional comma-separated list of top-level fields to keep on each result. Recommended for list/overview calls: omitting it returns every field, which on large lists can overflow the context window. Omit only when you need the full objects. Available fields: timestampCreatedAt, title, description, url, state, owner, repoName, repoHost, provider, id, files, clone, number, merged, draft, approved, authoredByMe, headBranch, baseBranch, headRepo." + }, + { + "name": "is_closed", + "type": "boolean", + "desc": "Set to true if you want to search for closed pull requests" + }, + { + "name": "page", + "type": "number", + "desc": "Optional parameter to specify the page number, defaults to 1" + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the git provider" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Set the repository name of the pull request. Required for Azure DevOps and Bitbucket" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Set the organization name of the pull request. Required for Azure DevOps and Bitbucket" + }, + { + "name": "reviewer", + "type": "boolean", + "desc": "Set to true to also include pull requests where you are a requested reviewer. Supported by github, github_enterprise, gitlab, gitlab_self_hosted; ignored for Azure DevOps and Bitbucket." + }, + { + "name": "with_branches", + "type": "boolean", + "desc": "Include head/base branch names on each result. For GitHub this triggers an extra API call per PR, so leave off unless branches are needed." + } + ] + }, + { + "name": "pull_request_create", + "description": "Create a new pull request", + "arguments": [ + { + "name": "assign_to_me", + "type": "boolean", + "desc": "Assign the newly created pull request to the current authenticated user when supported by the provider" + }, + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name of the pull request. Required for Azure DevOps" + }, + { + "name": "body", + "type": "string", + "desc": "The body/description of the pull request" + }, + { + "name": "is_draft", + "type": "boolean", + "desc": "Create as draft pull request" + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the git provider" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Set the repository name of the pull request. Required for Azure DevOps and Bitbucket" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Set the organization name of the pull request. Required for Azure DevOps and Bitbucket" + }, + { + "name": "source_branch", + "type": "string", + "desc": "Source branch from which the pull request will be created" + }, + { + "name": "target_branch", + "type": "string", + "desc": "Target branch where the pull request will be merged" + }, + { + "name": "title", + "type": "string", + "desc": "The title of the pull request" + } + ] + }, + { + "name": "pull_request_create_review", + "description": "Create a review for a pull request", + "arguments": [ + { + "name": "approve", + "type": "boolean", + "desc": "Set to true if you want to approve the pull request" + }, + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name of the pull request. Required for Azure DevOps" + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the git provider" + }, + { + "name": "pull_request_id", + "type": "string", + "desc": "ID of the pull request to create the review for" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Set the repository name of the pull request. Required for Azure DevOps and Bitbucket" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Set the organization name of the pull request. Required for Azure DevOps and Bitbucket" + }, + { + "name": "review", + "type": "string", + "desc": "Comment to add to the pull request review" + } + ] + }, + { + "name": "pull_request_get_comments", + "description": "Get all the comments in a pull requests", + "arguments": [ + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name of the pull request. Required for Azure DevOps" + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the git provider" + }, + { + "name": "pull_request_id", + "type": "string", + "desc": "ID of the pull request to add the comment to" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Set the repository name of the pull request" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Set the organization name of the pull request" + } + ] + }, + { + "name": "pull_request_get_detail", + "description": "Get an specific pull request", + "arguments": [ + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name of the pull request. Required for Azure DevOps" + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the git provider" + }, + { + "name": "pull_request_files", + "type": "boolean", + "desc": "Set to true if you want to retrieve the files changed in the pull request. Not supported by Azure DevOps." + }, + { + "name": "pull_request_id", + "type": "string", + "desc": "ID of the pull request to retrieve" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Set the repository name of the pull request" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Set the organization name of the pull request" + } + ] + }, + { + "name": "repository_get_file_content", + "description": "Get file content from a repository", + "arguments": [ + { + "name": "azure_project", + "type": "string", + "desc": "Optionally set the Azure DevOps project name of the pull request. Required for Azure DevOps" + }, + { + "name": "file_path", + "type": "string", + "desc": "File path to retrieve from the repository" + }, + { + "name": "provider", + "type": "string", + "desc": "Specify the git provider" + }, + { + "name": "ref", + "type": "string", + "desc": "Set the branch, tag, or commit SHA to retrieve the file from" + }, + { + "name": "repository_name", + "type": "string", + "desc": "Set the repository name of the pull request. Required for Azure DevOps and Bitbucket" + }, + { + "name": "repository_organization", + "type": "string", + "desc": "Set the organization name of the pull request. Required for Azure DevOps and Bitbucket" + } + ] + } +]