From 0b3c761fd53076e821656bb647278741f477404f Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 31 Oct 2026 00:00:47 +0200 Subject: [PATCH] feat(codex): add configurable local plugin Install one plugin before configuring a local GitLab instance or an existing HTTP MCP server. Preserve credentials, backend contracts and connection state across changes and restarts. Keep setup available during backend failures, preserve proxy and CA configuration, and fence asynchronous restoration and cancellation. Bound qualification with the SDK request deadline. Build and qualify standalone release ZIPs and checksums in CI, including clean failure of damaged archives. Keep packaging out of the public CLI. BREAKING CHANGE: Remove the gitlab-mcp plugin build command. Install the released plugin and configure its connection in the plugin settings. Closes #729 --- .github/workflows/ci-cd.yml | 54 +- .github/workflows/release-please.yml | 61 +- README.md | 19 +- packages/gitlab-mcp-db/README.md | 19 +- packages/gitlab-mcp/README.md | 19 +- packages/gitlab-mcp/README.md.in | 19 +- .../gitlab-mcp/docs/.vitepress/config.mts | 1 - packages/gitlab-mcp/docs/cli/index.md | 1 - packages/gitlab-mcp/docs/cli/plugin.md | 85 --- .../gitlab-mcp/docs/configuration/settings.md | 5 + .../docs/guide/installation/codex.md | 118 ++- packages/gitlab-mcp/docs/index.md | 15 +- packages/gitlab-mcp/docs/index.md.in | 7 + packages/gitlab-mcp/docs/tools/index.md | 4 +- packages/gitlab-mcp/docs/tools/index.md.in | 35 + packages/gitlab-mcp/eslint.config.mjs | 2 +- packages/gitlab-mcp/package.json | 7 +- .../gitlab-mcp/scripts/build-codex-plugin.mjs | 15 + .../scripts/build-plugin-marketplace.sh | 8 +- .../scripts/codex-plugin-lifecycle.mjs | 20 +- .../gitlab-mcp/scripts/codex-plugin-live.mjs | 163 +++++ .../gitlab-mcp/scripts/evaluation-process.mjs | 6 +- .../scripts/plugin-test-summary.mjs | 20 + .../scripts/qualify-codex-plugin.mjs | 137 ++++ .../gitlab-mcp/skills/gitlab-setup/SKILL.md | 7 + packages/gitlab-mcp/src/cli-utils.ts | 10 +- packages/gitlab-mcp/src/cli/plugin/build.ts | 83 ++- packages/gitlab-mcp/src/cli/plugin/command.ts | 95 --- packages/gitlab-mcp/src/cli/plugin/index.ts | 5 - packages/gitlab-mcp/src/cli/plugin/runtime.ts | 87 +++ packages/gitlab-mcp/src/main.ts | 6 - .../gitlab-mcp/src/plugin/authorization.ts | 255 +++++++ .../gitlab-mcp/src/plugin/connection-store.ts | 204 ++++++ packages/gitlab-mcp/src/plugin/connections.ts | 495 +++++++++++++ packages/gitlab-mcp/src/plugin/core.ts | 10 + packages/gitlab-mcp/src/plugin/main.ts | 36 + packages/gitlab-mcp/src/plugin/panel.ts | 35 + .../gitlab-mcp/src/plugin/refresh-guard.ts | 62 ++ packages/gitlab-mcp/src/plugin/server.ts | 351 +++++++++ packages/gitlab-mcp/src/server.ts | 2 +- .../tests/plugin/bootstrap.test.cjs | 94 +++ .../tests/plugin/connection.test.cjs | 679 ++++++++++++++++++ .../plugin/fixtures/hold-refresh-lock.cjs | 7 + .../tests/plugin/lifecycle.test.cjs | 133 ++++ .../tests/plugin/oauth-binding.test.cjs | 153 ++++ .../tests/plugin/oauth-errors.test.cjs | 100 +++ .../gitlab-mcp/tests/plugin/oauth.test.cjs | 256 +++++++ .../tests/plugin/refresh-lock.test.cjs | 93 +++ packages/gitlab-mcp/tests/plugin/runtime.cjs | 6 + .../gitlab-mcp/tests/plugin/server.test.cjs | 260 +++++++ .../tests/scripts/codex-live.test.mjs | 73 ++ .../tests/scripts/plugin-artifact.test.mjs | 144 ++++ .../gitlab-mcp/tests/unit/cli-utils.test.ts | 22 +- .../tests/unit/cli/plugin/build.test.ts | 121 ++++ .../tests/unit/cli/plugin/command.test.ts | 146 ---- .../unit/cli/plugin/connection-store.test.ts | 120 ++++ .../tests/unit/cli/plugin/panel.test.ts | 89 +++ .../tests/unit/cli/plugin/runtime.test.ts | 87 +++ packages/gitlab-mcp/tests/unit/main.test.ts | 14 - .../tests/unit/release/codex-archive.test.ts | 30 + plugins/gitlab-mcp/mcp.json | 12 +- .../gitlab-mcp/skills/gitlab-setup/SKILL.md | 7 + yarn.lock | 144 +++- 63 files changed, 4913 insertions(+), 460 deletions(-) delete mode 100644 packages/gitlab-mcp/docs/cli/plugin.md create mode 100644 packages/gitlab-mcp/scripts/build-codex-plugin.mjs create mode 100644 packages/gitlab-mcp/scripts/codex-plugin-live.mjs create mode 100644 packages/gitlab-mcp/scripts/plugin-test-summary.mjs create mode 100644 packages/gitlab-mcp/scripts/qualify-codex-plugin.mjs delete mode 100644 packages/gitlab-mcp/src/cli/plugin/command.ts delete mode 100644 packages/gitlab-mcp/src/cli/plugin/index.ts create mode 100644 packages/gitlab-mcp/src/cli/plugin/runtime.ts create mode 100644 packages/gitlab-mcp/src/plugin/authorization.ts create mode 100644 packages/gitlab-mcp/src/plugin/connection-store.ts create mode 100644 packages/gitlab-mcp/src/plugin/connections.ts create mode 100644 packages/gitlab-mcp/src/plugin/core.ts create mode 100644 packages/gitlab-mcp/src/plugin/main.ts create mode 100644 packages/gitlab-mcp/src/plugin/panel.ts create mode 100644 packages/gitlab-mcp/src/plugin/refresh-guard.ts create mode 100644 packages/gitlab-mcp/src/plugin/server.ts create mode 100644 packages/gitlab-mcp/tests/plugin/bootstrap.test.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/connection.test.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/fixtures/hold-refresh-lock.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/lifecycle.test.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/oauth-binding.test.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/oauth-errors.test.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/oauth.test.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/refresh-lock.test.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/runtime.cjs create mode 100644 packages/gitlab-mcp/tests/plugin/server.test.cjs create mode 100644 packages/gitlab-mcp/tests/scripts/codex-live.test.mjs create mode 100644 packages/gitlab-mcp/tests/scripts/plugin-artifact.test.mjs delete mode 100644 packages/gitlab-mcp/tests/unit/cli/plugin/command.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/cli/plugin/connection-store.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/cli/plugin/panel.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/cli/plugin/runtime.test.ts create mode 100644 packages/gitlab-mcp/tests/unit/release/codex-archive.test.ts diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index cdf0056f..0e3b8b36 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -100,15 +100,11 @@ jobs: echo "Manifest template is valid (JSON + required fields)" working-directory: packages/gitlab-mcp - # Builds the plugin with the directory submission rules from the built CLI, + # Builds the plugin with the directory submission rules, # so a missing asset, a bad skill header or an embedded secret fails the PR. - name: Validate Codex plugin package - run: >- - node dist/src/main.js plugin build --public - --endpoint https://mcp.example.com/mcp - --privacy-url https://example.com/privacy - --terms-url https://example.com/terms - --out "$RUNNER_TEMP/codex-plugin" + run: | + node -e 'require("./dist/src/cli/plugin/build.js").buildPlugin({flavor: "public", endpoint: "https://mcp.example.com/mcp", privacyPolicyUrl: "https://example.com/privacy", termsOfServiceUrl: "https://example.com/terms", outDir: process.env.RUNNER_TEMP + "/codex-plugin"})' working-directory: packages/gitlab-mcp # The repository marketplace (.agents/plugins and plugins/ at the repo root) is @@ -125,12 +121,38 @@ jobs: # Installs that package into a real Codex CLI and runs install, disable, enable, # upgrade, reinstall and uninstall against the installed copy. + - name: Build configurable Codex plugin archive + run: >- + node scripts/build-codex-plugin.mjs "$RUNNER_TEMP/codex-configurable" + working-directory: packages/gitlab-mcp + - name: Codex plugin lifecycle run: | npm install --prefix "$RUNNER_TEMP/codex-cli" --no-save --no-package-lock @openai/codex@0.163.0 node scripts/codex-plugin-lifecycle.mjs --codex "$RUNNER_TEMP/codex-cli/node_modules/.bin/codex" "$RUNNER_TEMP/codex-plugin" + node --test tests/scripts/plugin-artifact.test.mjs + node scripts/qualify-codex-plugin.mjs --archive "$RUNNER_TEMP/codex-configurable/gitlab-mcp-codex.zip" --coverage --codex "$RUNNER_TEMP/codex-cli/node_modules/.bin/codex" working-directory: packages/gitlab-mcp + - name: Upload Codex plugin archive + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7 + with: + name: codex-plugin + path: | + ${{ runner.temp }}/codex-configurable/gitlab-mcp-codex.zip + ${{ runner.temp }}/codex-configurable/gitlab-mcp-codex.zip.sha256 + compression-level: 0 + if-no-files-found: error + retention-days: 7 + + - name: Upload Codex plugin contract coverage + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./packages/gitlab-mcp/coverage/plugin.lcov.info + flags: codex-plugin + fail_ci_if_error: false + - name: Upload build artifacts uses: actions/upload-artifact@v7 with: @@ -340,22 +362,22 @@ jobs: steps: - name: Summary run: | - echo "## Pipeline Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + echo "## Pipeline Summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" if [[ "${{ needs.quality-checks.result }}" == "success" ]]; then - echo "✅ Quality checks: Passed" >> $GITHUB_STEP_SUMMARY + echo "✅ Quality checks: Passed" >> "$GITHUB_STEP_SUMMARY" else - echo "❌ Quality checks: Failed" >> $GITHUB_STEP_SUMMARY + echo "❌ Quality checks: Failed" >> "$GITHUB_STEP_SUMMARY" fi if [[ "${{ needs.docker-build.result }}" == "success" ]]; then - echo "✅ Docker build: Passed" >> $GITHUB_STEP_SUMMARY + echo "✅ Docker build: Passed" >> "$GITHUB_STEP_SUMMARY" elif [[ "${{ needs.docker-build.result }}" == "skipped" ]]; then - echo "⏭️ Docker build: Skipped" >> $GITHUB_STEP_SUMMARY + echo "⏭️ Docker build: Skipped" >> "$GITHUB_STEP_SUMMARY" else - echo "❌ Docker build: Failed" >> $GITHUB_STEP_SUMMARY + echo "❌ Docker build: Failed" >> "$GITHUB_STEP_SUMMARY" fi - echo "" >> $GITHUB_STEP_SUMMARY - echo "ℹ️ Release and publishing handled by [Release workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/release-please.yml)" >> $GITHUB_STEP_SUMMARY + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "ℹ️ Release and publishing handled by [Release workflow](${{ github.server_url }}/${{ github.repository }}/actions/workflows/release-please.yml)" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7f8a8056..f9ec42e3 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -170,6 +170,14 @@ jobs: run: ./scripts/prepare-release.sh "${{ needs.release-please.outputs.release-version }}" working-directory: packages/gitlab-mcp + - name: Build configurable Codex marketplace + working-directory: packages/gitlab-mcp + run: node scripts/build-codex-plugin.mjs codex-release + + - name: Verify the exact Codex release archive + working-directory: packages/gitlab-mcp + run: node scripts/qualify-codex-plugin.mjs --archive codex-release/gitlab-mcp-codex.zip + # Note: server.json version is updated by release-please extra-files. # Dynamic counts (README.md, package.json manifest) are updated by prepare-release.sh # locally for the build but NOT committed back — they'll be current in the next release PR. @@ -234,6 +242,17 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload Codex marketplace to GitHub Release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3 + with: + tag_name: ${{ needs.release-please.outputs.release-tag }} + fail_on_unmatched_files: true + files: | + packages/gitlab-mcp/codex-release/gitlab-mcp-codex.zip + packages/gitlab-mcp/codex-release/gitlab-mcp-codex.zip.sha256 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Publish Docker images - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -373,17 +392,36 @@ jobs: working-directory: packages/gitlab-mcp run: '"$GITHUB_WORKSPACE/mcp-publisher" publish' - # Job 3: Sync generated metadata into the open release PR - # When release-please updates (not merges) the release PR, regenerate the - # dynamic artifacts (package.json MCP manifest + tool counts, README counts, - # server.json description) and commit them onto the PR branch so the committed - # state always matches what will be published — alongside the version bump and - # CHANGELOG that already live on that branch. - # - # Loop-safe: this pushes to the release PR branch, and the workflow only triggers - # on push to main, so the commit never re-triggers the workflow. release-please - # may rebuild its PR branch on the next push to main; this job re-runs then and - # re-applies the metadata, so the PR is current whenever it is merged. + notify-plugin-marketplace: + name: Refresh the shared plugin marketplace + runs-on: ubuntu-latest + needs: [release-please, post-release] + if: needs.release-please.outputs.release-created == 'true' && needs.post-release.result == 'success' + permissions: {} + steps: + - name: Generate marketplace dispatch token + uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3 + id: marketplace-token + with: + app-id: ${{ secrets.RELEASER_APP_ID }} + private-key: ${{ secrets.RELEASER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: repo + permission-contents: write + + - name: Request publication of the released plugin + env: + GH_TOKEN: ${{ steps.marketplace-token.outputs.token }} + RELEASE_TAG: ${{ needs.release-please.outputs.release-tag }} + run: | + jq -n --arg tag "$RELEASE_TAG" '{event_type: "refresh-gitlab-mcp-plugin", client_payload: {release_tag: $tag}}' \ + | gh api --method POST repos/structured-world/repo/dispatches --input - + + # Sync generated metadata into the open release PR. After its version bump, + # regenerate package manifests, README counts and the registry description. + # This pushes to the release PR branch while the workflow triggers on main, + # so it does not create a publication loop. If release-please rebuilds that + # branch on the next main push, this job reapplies the generated metadata. sync-release-pr: name: Sync generated metadata into release PR runs-on: ubuntu-latest @@ -493,6 +531,7 @@ jobs: echo "📦 npm: @structured-world/gitlab-mcp@${{ needs.release-please.outputs.release-version }}" echo "🐳 Docker: ghcr.io/${{ env.IMAGE_NAME }}:${{ needs.release-please.outputs.release-version }}" echo "📦 MCPB: gitlab-mcp-${{ needs.release-please.outputs.release-version }}.mcpb" + echo "📦 Codex: gitlab-mcp-codex.zip and SHA-256 checksum" echo "🌐 MCP Registry: io.github.structured-world/gitlab-mcp" else echo "❌ Post-release publish failed" diff --git a/README.md b/README.md index 194f7d3a..60b1134d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Advanced GitLab MCP server: 64 CQRS tools exposing 235 GitLab operations across 26 entity types. The tool catalog and parameters are filtered to each instance's GitLab version, tier, and token scopes, so the agent sees only what the connected instance actually supports. [![Install in Claude Desktop](https://img.shields.io/badge/Claude_Desktop-Install_Extension-F97316?style=for-the-badge)](https://gitlab-mcp.sw.foundation/downloads/gitlab-mcp-12.1.0.mcpb) +[![Set up in Codex](https://img.shields.io/badge/Codex-Setup_%26_Download-111827?style=for-the-badge)](https://gitlab-mcp.sw.foundation/guide/installation/codex#install-from-a-release) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode-insiders:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) @@ -38,7 +39,7 @@ This is a monorepo with two npm packages: | Client | Setup | |--------|-------| -| **Codex (CLI and desktop)** | [Local MCP registration or remote HTTP server](https://gitlab-mcp.sw.foundation/guide/installation/codex) | +| **Codex (CLI and desktop)** | [Configurable local plugin, local MCP registration or HTTP server](https://gitlab-mcp.sw.foundation/guide/installation/codex) | | **Antigravity CLI (`agy`)** | [Workspace MCP configuration and tested compatibility](https://gitlab-mcp.sw.foundation/clients/antigravity) | | Claude Desktop | [Desktop extension or MCP configuration](https://gitlab-mcp.sw.foundation/clients/claude-desktop) | | Claude Code | [CLI or MCP configuration](https://gitlab-mcp.sw.foundation/clients/claude-code) | @@ -48,6 +49,22 @@ This is a monorepo with two npm packages: | Cline | [MCP configuration](https://gitlab-mcp.sw.foundation/clients/cline) | | Roo Code | [MCP configuration](https://gitlab-mcp.sw.foundation/clients/roo-code) | +The Codex plugin can be installed before configuring a target. Its connection +panel supports a GitLab instance with a locally started core server, or an existing +HTTP MCP server. The local plugin requires Node.js 24+; credentials are entered in +its private panel. See the installation guide for marketplace setup and authentication. + +The [shared sw.foundation marketplace](https://repo.sw.foundation/plugins/) lists +verified releases. Add it once with `codex plugin marketplace add structured-world/repo --sparse .agents/plugins`, +then install GitLab MCP from that catalog after its first configurable plugin release. + +Download the ready-made [Codex marketplace ZIP](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip) +and its [SHA-256 checksum](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip.sha256) +from GitHub Releases. It includes the local runtime and dependencies, separately from +the Claude Desktop MCPB bundles. Add the extracted marketplace once, then install +through Codex. An install link requires an already configured marketplace; downloading +the ZIP does not install it automatically. Older releases may not contain these assets. + With the Codex CLI installed and `GITLAB_API_URL` and `GITLAB_TOKEN` set in your environment, run `npx @structured-world/gitlab-mcp install --codex` to register a local server. For Antigravity, add the server diff --git a/packages/gitlab-mcp-db/README.md b/packages/gitlab-mcp-db/README.md index 6e1d26a0..08a25cf6 100644 --- a/packages/gitlab-mcp-db/README.md +++ b/packages/gitlab-mcp-db/README.md @@ -5,6 +5,7 @@ Advanced GitLab MCP server: 64 CQRS tools exposing 235 GitLab operations across 26 entity types. The tool catalog and parameters are filtered to each instance's GitLab version, tier, and token scopes, so the agent sees only what the connected instance actually supports. [![Install in Claude Desktop](https://img.shields.io/badge/Claude_Desktop-Install_Extension-F97316?style=for-the-badge)](https://gitlab-mcp.sw.foundation/downloads/gitlab-mcp-12.1.0.mcpb) +[![Set up in Codex](https://img.shields.io/badge/Codex-Setup_%26_Download-111827?style=for-the-badge)](https://gitlab-mcp.sw.foundation/guide/installation/codex#install-from-a-release) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode-insiders:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) @@ -38,7 +39,7 @@ This is a monorepo with two npm packages: | Client | Setup | |--------|-------| -| **Codex (CLI and desktop)** | [Local MCP registration or remote HTTP server](https://gitlab-mcp.sw.foundation/guide/installation/codex) | +| **Codex (CLI and desktop)** | [Configurable local plugin, local MCP registration or HTTP server](https://gitlab-mcp.sw.foundation/guide/installation/codex) | | **Antigravity CLI (`agy`)** | [Workspace MCP configuration and tested compatibility](https://gitlab-mcp.sw.foundation/clients/antigravity) | | Claude Desktop | [Desktop extension or MCP configuration](https://gitlab-mcp.sw.foundation/clients/claude-desktop) | | Claude Code | [CLI or MCP configuration](https://gitlab-mcp.sw.foundation/clients/claude-code) | @@ -48,6 +49,22 @@ This is a monorepo with two npm packages: | Cline | [MCP configuration](https://gitlab-mcp.sw.foundation/clients/cline) | | Roo Code | [MCP configuration](https://gitlab-mcp.sw.foundation/clients/roo-code) | +The Codex plugin can be installed before configuring a target. Its connection +panel supports a GitLab instance with a locally started core server, or an existing +HTTP MCP server. The local plugin requires Node.js 24+; credentials are entered in +its private panel. See the installation guide for marketplace setup and authentication. + +The [shared sw.foundation marketplace](https://repo.sw.foundation/plugins/) lists +verified releases. Add it once with `codex plugin marketplace add structured-world/repo --sparse .agents/plugins`, +then install GitLab MCP from that catalog after its first configurable plugin release. + +Download the ready-made [Codex marketplace ZIP](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip) +and its [SHA-256 checksum](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip.sha256) +from GitHub Releases. It includes the local runtime and dependencies, separately from +the Claude Desktop MCPB bundles. Add the extracted marketplace once, then install +through Codex. An install link requires an already configured marketplace; downloading +the ZIP does not install it automatically. Older releases may not contain these assets. + With the Codex CLI installed and `GITLAB_API_URL` and `GITLAB_TOKEN` set in your environment, run `npx @structured-world/gitlab-mcp install --codex` to register a local server. For Antigravity, add the server diff --git a/packages/gitlab-mcp/README.md b/packages/gitlab-mcp/README.md index 6e1d26a0..08a25cf6 100644 --- a/packages/gitlab-mcp/README.md +++ b/packages/gitlab-mcp/README.md @@ -5,6 +5,7 @@ Advanced GitLab MCP server: 64 CQRS tools exposing 235 GitLab operations across 26 entity types. The tool catalog and parameters are filtered to each instance's GitLab version, tier, and token scopes, so the agent sees only what the connected instance actually supports. [![Install in Claude Desktop](https://img.shields.io/badge/Claude_Desktop-Install_Extension-F97316?style=for-the-badge)](https://gitlab-mcp.sw.foundation/downloads/gitlab-mcp-12.1.0.mcpb) +[![Set up in Codex](https://img.shields.io/badge/Codex-Setup_%26_Download-111827?style=for-the-badge)](https://gitlab-mcp.sw.foundation/guide/installation/codex#install-from-a-release) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode-insiders:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) @@ -38,7 +39,7 @@ This is a monorepo with two npm packages: | Client | Setup | |--------|-------| -| **Codex (CLI and desktop)** | [Local MCP registration or remote HTTP server](https://gitlab-mcp.sw.foundation/guide/installation/codex) | +| **Codex (CLI and desktop)** | [Configurable local plugin, local MCP registration or HTTP server](https://gitlab-mcp.sw.foundation/guide/installation/codex) | | **Antigravity CLI (`agy`)** | [Workspace MCP configuration and tested compatibility](https://gitlab-mcp.sw.foundation/clients/antigravity) | | Claude Desktop | [Desktop extension or MCP configuration](https://gitlab-mcp.sw.foundation/clients/claude-desktop) | | Claude Code | [CLI or MCP configuration](https://gitlab-mcp.sw.foundation/clients/claude-code) | @@ -48,6 +49,22 @@ This is a monorepo with two npm packages: | Cline | [MCP configuration](https://gitlab-mcp.sw.foundation/clients/cline) | | Roo Code | [MCP configuration](https://gitlab-mcp.sw.foundation/clients/roo-code) | +The Codex plugin can be installed before configuring a target. Its connection +panel supports a GitLab instance with a locally started core server, or an existing +HTTP MCP server. The local plugin requires Node.js 24+; credentials are entered in +its private panel. See the installation guide for marketplace setup and authentication. + +The [shared sw.foundation marketplace](https://repo.sw.foundation/plugins/) lists +verified releases. Add it once with `codex plugin marketplace add structured-world/repo --sparse .agents/plugins`, +then install GitLab MCP from that catalog after its first configurable plugin release. + +Download the ready-made [Codex marketplace ZIP](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip) +and its [SHA-256 checksum](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip.sha256) +from GitHub Releases. It includes the local runtime and dependencies, separately from +the Claude Desktop MCPB bundles. Add the extracted marketplace once, then install +through Codex. An install link requires an already configured marketplace; downloading +the ZIP does not install it automatically. Older releases may not contain these assets. + With the Codex CLI installed and `GITLAB_API_URL` and `GITLAB_TOKEN` set in your environment, run `npx @structured-world/gitlab-mcp install --codex` to register a local server. For Antigravity, add the server diff --git a/packages/gitlab-mcp/README.md.in b/packages/gitlab-mcp/README.md.in index 66c78ac3..bf3c7740 100644 --- a/packages/gitlab-mcp/README.md.in +++ b/packages/gitlab-mcp/README.md.in @@ -5,6 +5,7 @@ Advanced GitLab MCP server: __TOOL_COUNT__ CQRS tools exposing __ACTION_COUNT__ GitLab operations across __ENTITY_COUNT__ entity types. The tool catalog and parameters are filtered to each instance's GitLab version, tier, and token scopes, so the agent sees only what the connected instance actually supports. [![Install in Claude Desktop](https://img.shields.io/badge/Claude_Desktop-Install_Extension-F97316?style=for-the-badge)](https://gitlab-mcp.sw.foundation/downloads/gitlab-mcp-__VERSION__.mcpb) +[![Set up in Codex](https://img.shields.io/badge/Codex-Setup_%26_Download-111827?style=for-the-badge)](https://gitlab-mcp.sw.foundation/guide/installation/codex#install-from-a-release) [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode-insiders:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) @@ -38,7 +39,7 @@ This is a monorepo with two npm packages: | Client | Setup | |--------|-------| -| **Codex (CLI and desktop)** | [Local MCP registration or remote HTTP server](https://gitlab-mcp.sw.foundation/guide/installation/codex) | +| **Codex (CLI and desktop)** | [Configurable local plugin, local MCP registration or HTTP server](https://gitlab-mcp.sw.foundation/guide/installation/codex) | | **Antigravity CLI (`agy`)** | [Workspace MCP configuration and tested compatibility](https://gitlab-mcp.sw.foundation/clients/antigravity) | | Claude Desktop | [Desktop extension or MCP configuration](https://gitlab-mcp.sw.foundation/clients/claude-desktop) | | Claude Code | [CLI or MCP configuration](https://gitlab-mcp.sw.foundation/clients/claude-code) | @@ -48,6 +49,22 @@ This is a monorepo with two npm packages: | Cline | [MCP configuration](https://gitlab-mcp.sw.foundation/clients/cline) | | Roo Code | [MCP configuration](https://gitlab-mcp.sw.foundation/clients/roo-code) | +The Codex plugin can be installed before configuring a target. Its connection +panel supports a GitLab instance with a locally started core server, or an existing +HTTP MCP server. The local plugin requires Node.js 24+; credentials are entered in +its private panel. See the installation guide for marketplace setup and authentication. + +The [shared sw.foundation marketplace](https://repo.sw.foundation/plugins/) lists +verified releases. Add it once with `codex plugin marketplace add structured-world/repo --sparse .agents/plugins`, +then install GitLab MCP from that catalog after its first configurable plugin release. + +Download the ready-made [Codex marketplace ZIP](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip) +and its [SHA-256 checksum](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip.sha256) +from GitHub Releases. It includes the local runtime and dependencies, separately from +the Claude Desktop MCPB bundles. Add the extracted marketplace once, then install +through Codex. An install link requires an already configured marketplace; downloading +the ZIP does not install it automatically. Older releases may not contain these assets. + With the Codex CLI installed and `GITLAB_API_URL` and `GITLAB_TOKEN` set in your environment, run `npx @structured-world/gitlab-mcp install --codex` to register a local server. For Antigravity, add the server diff --git a/packages/gitlab-mcp/docs/.vitepress/config.mts b/packages/gitlab-mcp/docs/.vitepress/config.mts index 446a1154..72db696a 100644 --- a/packages/gitlab-mcp/docs/.vitepress/config.mts +++ b/packages/gitlab-mcp/docs/.vitepress/config.mts @@ -292,7 +292,6 @@ export default defineConfig({ { text: "setup", link: "/cli/setup" }, { text: "init", link: "/cli/init" }, { text: "install", link: "/cli/install" }, - { text: "plugin", link: "/cli/plugin" }, { text: "instances", link: "/cli/instances" }, { text: "docker", link: "/cli/docker" }, { text: "list-tools", link: "/cli/list-tools" }, diff --git a/packages/gitlab-mcp/docs/cli/index.md b/packages/gitlab-mcp/docs/cli/index.md index 28411bfc..339c85b1 100644 --- a/packages/gitlab-mcp/docs/cli/index.md +++ b/packages/gitlab-mcp/docs/cli/index.md @@ -18,7 +18,6 @@ GitLab MCP provides several CLI commands for setup, installation, and management | [`gitlab-mcp setup`](/cli/setup) | Interactive setup wizard | Full environment detection and configuration | | [`gitlab-mcp init`](/cli/init) | Quick local setup | Alias for `setup --mode=local` | | [`gitlab-mcp install`](/cli/install) | Install to MCP clients | Detect and configure AI coding assistants | -| [`gitlab-mcp plugin`](/cli/plugin) | Codex plugin package | Build the plugin for an MCP endpoint you operate | | [`gitlab-mcp docker`](/cli/docker) | Docker management | Container lifecycle and instance management | | [`gitlab-mcp list-tools`](/cli/list-tools) | Tool documentation | Browse and export available tools | diff --git a/packages/gitlab-mcp/docs/cli/plugin.md b/packages/gitlab-mcp/docs/cli/plugin.md deleted file mode 100644 index 1241b7dc..00000000 --- a/packages/gitlab-mcp/docs/cli/plugin.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -title: Build the Codex Plugin Package -description: 'Use gitlab-mcp plugin build to package GitLab MCP as a Codex and ChatGPT plugin for an MCP endpoint you operate, as a local marketplace and a ZIP for directory submission.' -head: - - - meta - - name: keywords - content: gitlab mcp codex plugin, codex plugin marketplace, chatgpt plugin, agent plugins, plugin zip, plugins directory ---- - -# Build the Codex Plugin Package - -`gitlab-mcp plugin build` packages GitLab MCP as a plugin for Codex and the ChatGPT desktop app. The package connects to an MCP endpoint you operate: a [GitLab MCP server in HTTP mode](/guide/transport) with [OAuth](/security/oauth) enabled. Users sign in with their GitLab account in the browser when they install it; the package holds no credentials. - -## Usage - -```bash -npx @structured-world/gitlab-mcp plugin build --endpoint [options] -``` - -## Options - -| Flag | Description | -| --------------------- | ------------------------------------------------------------------- | -| `--endpoint ` | MCP endpoint, for example `https://mcp.example.com/mcp`. Required | -| `--out ` | Output directory. Default: `./gitlab-mcp-plugin` | -| `--public` | Apply the Plugins Directory submission rules | -| `--privacy-url ` | Privacy policy of the endpoint operator. Required with `--public` | -| `--terms-url ` | Terms of service of the endpoint operator. Required with `--public` | - -The endpoint must use HTTPS. Plain HTTP is accepted only on loopback (`localhost`, `127.0.0.1`, `::1`), for a server on the same machine. With `--public` the host must also be publicly reachable: not a private or reserved address, nor a special-purpose address the IANA registries mark as not globally reachable, and not a special-use domain such as `.test`, `.invalid`, `.local` or `.internal`. - -## Output - -``` -gitlab-mcp-plugin/ -├── .agents/plugins/marketplace.json # local marketplace listing the plugin -├── plugins/gitlab-mcp/ # the plugin: plugin.json, mcp.json, skills/, assets/ -└── gitlab-mcp-.zip # the same plugin as one archive -``` - -`assets/` holds the logo, a composer icon for the light and the dark theme, and screenshots of the GitLab connection panel. - -The version is the version of the installed package. A rebuild replaces only `plugins/gitlab-mcp/` and produces a byte-identical ZIP; the command prints its SHA-256. When `--out` already holds a marketplace, the GitLab entry is added to or replaced in its catalog and the other plugins, its name and its display name stay. - -Before writing anything the command checks the package against the Agent Plugins 1.0.0 schemas and the directory rules: listing text limits, HTTPS listing links without credentials, square icons of 48 to 4096 px, skill headers, a single MCP server without headers, and no hooks, app references, tokens, private keys, local paths or, with `--public`, private network addresses and a display name ending in "MCP", "MCP Server" or "Plugin". Every problem is listed and nothing is written. - -## Team or private deployment - -Build against your server and add the output as a marketplace: - -```bash -npx @structured-world/gitlab-mcp plugin build --endpoint https://mcp.example.com/mcp -codex plugin marketplace add ./gitlab-mcp-plugin -``` - -Then open the Plugins Directory in Codex or the ChatGPT desktop app, choose the **GitLab by Structured World** marketplace and install the plugin. Share the output directory, for example in a Git repository, so the team adds the same marketplace. - -## Local development server - -The repository is itself a marketplace with a plugin for a GitLab MCP server running on your computer in [HTTP mode](/guide/transport) on the default port (`http://127.0.0.1:3002/mcp`): - -```bash -PORT=3002 GITLAB_API_URL=https://gitlab.example.com GITLAB_TOKEN=glpat-... \ - npx @structured-world/gitlab-mcp -codex plugin marketplace add structured-world/gitlab-mcp -codex plugin add gitlab-mcp@gitlab-mcp-dev -``` - -The server uses your token, so the plugin needs no sign-in. Use it to try the skills and the connection panel; for a shared server, build the plugin for its address as above. - -## Directory submission - -```bash -npx @structured-world/gitlab-mcp plugin build --public \ - --endpoint https://mcp.example.com/mcp \ - --privacy-url https://example.com/privacy \ - --terms-url https://example.com/terms -``` - -Upload the ZIP in the OpenAI platform dashboard. The directory also requires a verified publisher and domain, review test cases, a reviewer account and a demo recording; those are entered in the dashboard, not packaged. - -## See Also - -- [Codex installation](/guide/installation/codex) -- [`gitlab-mcp install`](/cli/install), to register a local server in Codex instead diff --git a/packages/gitlab-mcp/docs/configuration/settings.md b/packages/gitlab-mcp/docs/configuration/settings.md index 7c90909e..38b8d8bf 100644 --- a/packages/gitlab-mcp/docs/configuration/settings.md +++ b/packages/gitlab-mcp/docs/configuration/settings.md @@ -13,6 +13,11 @@ Each GitLab account that uses the server has its own settings, and each chat can them for itself. Settings only narrow what the server administrator allows; they never widen it. +For the configurable Codex plugin, its settings page also opens a private connection +panel before authentication. That panel chooses local GitLab execution or an existing +HTTP MCP server and manages credentials on this computer. Connection selection is +separate from the three backend policy layers below. See [Codex setup](/guide/installation/codex). + ## Three layers | Layer | Who sets it | Applies to | Kept | diff --git a/packages/gitlab-mcp/docs/guide/installation/codex.md b/packages/gitlab-mcp/docs/guide/installation/codex.md index 8bfcd5f2..46a2f936 100644 --- a/packages/gitlab-mcp/docs/guide/installation/codex.md +++ b/packages/gitlab-mcp/docs/guide/installation/codex.md @@ -1,6 +1,6 @@ --- title: Codex Installation Guide -description: 'Install GitLab MCP in Codex and the ChatGPT desktop app as a plugin connected to a GitLab MCP server, or register a local server with gitlab-mcp install.' +description: 'Install the local GitLab MCP plugin in Codex desktop, then configure a GitLab instance or an existing HTTP MCP server. Standalone registration is also supported.' head: - - meta - name: keywords @@ -11,25 +11,122 @@ head: GitLab MCP reaches Codex and the ChatGPT desktop app in two ways: -| Path | Runs where | Sign-in | Use when | -| ----------------------------- | -------------------------------- | --------------------------- | --------------------------------------- | -| [Plugin](#plugin) | A GitLab MCP server in HTTP mode | GitLab OAuth in the browser | A team or company runs a shared server | -| [Local server](#local-server) | Your computer, started by Codex | Personal access token | You work alone or offline from a server | +| Path | Runs where | Sign-in | Use when | +| ----------------------------- | ----------------------------------------- | ------------------------ | ---------------------------------------- | +| [Plugin](#plugin) | Local core or an existing HTTP MCP server | Token or browser sign-in | Configure either mode after installation | +| [Local server](#local-server) | Your computer, started by Codex | Personal access token | You work alone or offline from a server | Both can be installed side by side with Claude Code; neither touches Claude configuration. +Plugin chats share the selected connection. Updating OAuth credentials keeps that +selection and existing transports; choosing another account or disconnecting +invalidates older callbacks. Refresh operations are coordinated across local +plugin processes so a rotating refresh token is used only once. Process termination +releases coordination automatically, without a stale lease. + ## Plugin -The plugin bundles the connection to a GitLab MCP server, the GitLab skills and the onboarding guide. It needs a server running in [HTTP mode](/guide/transport) with [OAuth](/security/oauth); whoever operates it builds the plugin for its address with [`gitlab-mcp plugin build`](/cli/plugin): +The local plugin starts before a target is configured. It includes the core runtime, +production dependencies, GitLab skills and a private connection panel. Install +Node.js 24 or later. The Node executable itself is not bundled. + +### Shared sw.foundation marketplace + +The shared [sw.foundation catalog](https://repo.sw.foundation/plugins/) lists +verified released plugins. After the marketplace is published, add it once: ```bash -npx @structured-world/gitlab-mcp plugin build --endpoint https://mcp.example.com/mcp -codex plugin marketplace add ./gitlab-mcp-plugin +codex plugin marketplace add structured-world/repo --sparse .agents/plugins ``` -In Codex or the ChatGPT desktop app, open the Plugins Directory, choose the **GitLab by Structured World** marketplace and install the plugin. Codex opens GitLab in the browser to sign in. Then open the plugin's settings to choose a preset, read-only mode and the projects a chat may use; see [Account settings](/configuration/settings). +[Open shared marketplace installation in Codex](codex://plugins/install/gitlab-mcp?marketplace=sw-foundation) + +The GitLab entry appears after the configurable plugin's first successful release. +It references the exact verified source commit and published npm runtime. Install +Node.js 24+ and npm; the runtime downloads on first use. Refresh the catalog with +`codex plugin marketplace upgrade sw-foundation` and review plugin updates in Codex. +For a standalone download with its dependencies already included, use the ZIP below. + +### Install from a release + +Download the [Codex marketplace ZIP](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip) +and its [SHA-256 checksum](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip.sha256). +The release workflow builds these separately from the Claude Desktop MCPB bundles. +Older releases may not contain these assets; for unreleased changes, build from source. +Successful pull request checks also publish a `codex-plugin` Actions artifact +containing the validated ZIP and checksum for desktop qualification before release. + +Verify the ZIP against the checksum from the same release (`shasum -a 256 -c +gitlab-mcp-codex.zip.sha256` on macOS, `sha256sum -c` on Linux, or compare +`Get-FileHash` on Windows). Extract the whole archive into `gitlab-mcp-codex`, +including its hidden `.agents` directory, then register that directory: + +```bash +codex plugin marketplace add ./gitlab-mcp-codex +``` + +[Open installation in Codex](codex://plugins/install/gitlab-mcp?marketplace=gitlab-mcp) + +The button requires the marketplace registration above. It opens Codex's install +flow, where you review and confirm installation. Alternatively, run +`codex plugin add gitlab-mcp@gitlab-mcp`. No GitLab address or credentials are +needed until you open the connection panel after installation. + +### Install from the repository marketplace + +Once the connector version has been published to npm, you can avoid downloading +and extracting a ZIP. Install Node.js 24+ and npm, then add the repository once: + +```bash +codex plugin marketplace add structured-world/gitlab-mcp +``` -To update, rebuild into the same directory with the new package version and install the plugin again, from the Plugins Directory or with `codex plugin add gitlab-mcp@gitlab-mcp`; Codex replaces the cached copy. Disabling the plugin stops its server and keeps it installed. Removing it (`codex plugin remove gitlab-mcp@gitlab-mcp`) deletes the cached copy and leaves the rest of `config.toml`, Claude configuration and your account settings on the server as they are; the settings apply again when you reinstall. +[Open repository installation in Codex](codex://plugins/install/gitlab-mcp?marketplace=gitlab-mcp-dev) + +This marketplace uses the exact npm version declared in its manifest and downloads +it on first use. A Git ref containing unpublished code does not make that npm +version available. + +### Configure the installed plugin + +You can also install from that marketplace in the Plugins Directory. Open the +plugin's settings and choose **Set up, change or reconnect GitLab**, or ask to open +the GitLab connection panel. Choose either: + +- **GitLab instance, run MCP locally:** enter the GitLab address and a personal + access token. The plugin starts and stops the core process itself. GitLab 16.0+ + is supported; no separately started HTTP server, PostgreSQL or Prisma is needed. +- **Existing GitLab MCP server:** enter its Streamable HTTP endpoint and choose + browser sign-in, a bearer token, or no authentication as configured by its operator. + For OAuth, select **Continue browser sign-in**, authorize, then **Refresh status**. + The server must support OAuth discovery and registration for browser sign-in. + +Enter secrets only in the password field, never in the conversation. An unsuccessful +change leaves the saved connection intact. **Cancel setup** ends pending sign-in; +**Reconnect saved connection** retries it; **Disconnect** deletes saved credentials +and stops the local child or closes the remote transport. This does not revoke a +GitLab PAT or remote grant at its issuer. + +For slow local instances, **Local connection options** exposes the core initialization +budget (`GITLAB_INIT_TIMEOUT_MS`). Leaving it empty keeps the core default of 5000 ms. +The panel accepts 1000 to 45000 ms, reserving time for the host's request deadline. +The plugin does not increase it automatically after a failed connection. + +The verified identity appears after connecting. Account presets, scope, read-only +mode and tool groups remain at the selected backend; see [Account settings](/configuration/settings). +Changing a connection applies to this user's other plugin processes as well. + +Codex [install links](https://learn.chatgpt.com/docs/app/commands#plugin-install) +work only with known marketplaces. They do not register a repository or import a +ZIP URL. Public local-MCP directory listings separately require OpenAI approval. + +To update, download and extract the new release archive into the same directory and +install again. Codex replaces the cached copy. Connection configuration lives in the +host-provided `PLUGIN_DATA` directory, independently of the cached version. +Disable or remove the plugin through Codex to stop its transport and local child. +Disconnect first if you also want to erase saved credentials. Claude configuration, +standalone registrations and account settings on a remote server remain unchanged. +No separate MCP entry is created by the configurable plugin. ### Moving from a local server to the plugin @@ -83,6 +180,5 @@ GITLAB_TOKEN = "glpat-..." ## See Also -- [`gitlab-mcp plugin`](/cli/plugin) - [`gitlab-mcp install`](/cli/install) - [Codex workflows](/guides/codex-workflows) diff --git a/packages/gitlab-mcp/docs/index.md b/packages/gitlab-mcp/docs/index.md index 3a4b0b30..172fdc7e 100644 --- a/packages/gitlab-mcp/docs/index.md +++ b/packages/gitlab-mcp/docs/index.md @@ -1,7 +1,7 @@ --- layout: home titleTemplate: false -description: "Advanced GitLab MCP server with 58 tools across 26 entity types" +description: "Advanced GitLab MCP server with 64 tools across 26 entity types" head: - - meta - name: keywords @@ -9,7 +9,7 @@ head: hero: name: GitLab MCP server - tagline: Connect AI agents to GitLab with 58 tools across 26 entity types + tagline: Connect AI agents to GitLab with 64 tools across 26 entity types image: src: /logo-hero.png alt: GitLab MCP Logo @@ -26,7 +26,7 @@ hero: features: - icon: "\U0001F527" - title: 58 Tools, 26 Entities + title: 64 Tools, 26 Entities details: Complete GitLab API coverage — projects, merge requests, pipelines, work items, wikis, and more. - icon: "\U0001F517" title: Multi-Instance Support @@ -44,10 +44,17 @@ features: ## One-Click Install -Install in Claude Desktop +Install in Claude Desktop [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode-insiders:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) +**Codex:** download the [ready-made marketplace ZIP](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip) +and [checksum](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip.sha256). +Follow [Codex setup](/guide/installation/codex#install-from-a-release) to add its +marketplace once, then open the installation button in Codex. Configure a GitLab +instance or an existing MCP server in the plugin's private connection panel. +Node.js 24+ is required. This is separate from the Claude Desktop extension. + ::: warning After one-click install You must configure `GITLAB_TOKEN` in your MCP client settings before the server can connect. See the [Installation Guide](/guide/installation/npm) for token setup. ::: diff --git a/packages/gitlab-mcp/docs/index.md.in b/packages/gitlab-mcp/docs/index.md.in index 47f52bc0..f87f0d69 100644 --- a/packages/gitlab-mcp/docs/index.md.in +++ b/packages/gitlab-mcp/docs/index.md.in @@ -48,6 +48,13 @@ features: [![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_MCP_Server-007ACC?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_MCP_Server-24bfa5?style=for-the-badge&logo=visualstudiocode&logoColor=white)](vscode-insiders:mcp/install?%7B%22name%22%3A%22gitlab-mcp%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40structured-world%2Fgitlab-mcp%22%5D%7D) +**Codex:** download the [ready-made marketplace ZIP](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip) +and [checksum](https://github.com/structured-world/gitlab-mcp/releases/latest/download/gitlab-mcp-codex.zip.sha256). +Follow [Codex setup](/guide/installation/codex#install-from-a-release) to add its +marketplace once, then open the installation button in Codex. Configure a GitLab +instance or an existing MCP server in the plugin's private connection panel. +Node.js 24+ is required. This is separate from the Claude Desktop extension. + ::: warning After one-click install You must configure `GITLAB_TOKEN` in your MCP client settings before the server can connect. See the [Installation Guide](/guide/installation/npm) for token setup. ::: diff --git a/packages/gitlab-mcp/docs/tools/index.md b/packages/gitlab-mcp/docs/tools/index.md index b79b638f..806b3e65 100644 --- a/packages/gitlab-mcp/docs/tools/index.md +++ b/packages/gitlab-mcp/docs/tools/index.md @@ -1,6 +1,6 @@ --- title: Tool Reference -description: "Complete reference for 58 GitLab MCP tools across 26 entity types. Explore browse_* query and manage_* command operations organized by category with feature flags." +description: "Complete reference for 64 GitLab MCP tools across 26 entity types. Explore browse_* query and manage_* command operations organized by category with feature flags." head: - - meta - name: keywords @@ -9,7 +9,7 @@ head: # Tool Reference -GitLab MCP Server provides **58 tools** across 26 entity types. +GitLab MCP Server provides **64 tools** across 26 entity types. ## Architecture diff --git a/packages/gitlab-mcp/docs/tools/index.md.in b/packages/gitlab-mcp/docs/tools/index.md.in index 0b0ea806..a21eba18 100644 --- a/packages/gitlab-mcp/docs/tools/index.md.in +++ b/packages/gitlab-mcp/docs/tools/index.md.in @@ -56,6 +56,28 @@ Each tool accepts an `action` parameter selecting the specific operation. | `browse_refs` | Query | Browse branches and tags | | `manage_ref` | Command | Create/protect branches and tags | +Protected-tag listing, protection and unprotection are available on every GitLab +tier. For branch and tag protection, `allowed_to_*` access lists are available in +EE: role entries (`access_level`) work on Free EE, while individual user/group +grants require Premium or Ultimate and the appropriate project membership. +Code-owner approval requires Premium or Ultimate. + +Protection changes are checked against the returned GitLab entity. If GitLab +ignores a requested access entry, `unprotect_access_level` or code-owner setting, the result includes +`not_applied` with the setting name (including the input array index), requested +value and required capability. Other changes may already have taken effect; +inspect the returned protection before retrying. Fully applied responses retain +their usual shape. CE callers can use `create_access_level`, `push_access_level` +and `merge_access_level` for role-based protection. +The standalone `unprotect_access_level` parameter requires EE too; it is checked +against the returned `unprotect_access_levels`, including when CE ignores it. + +These capabilities are verified against GitLab 16.0 sources: +[protected tags](https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/lib/api/protected_tags.rb), +[EE access-list parameters](https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/lib/ee/api/helpers/protected_branches_helpers.rb), +[role and licensed user/group grants](https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/app/models/concerns/ee/protected_ref_access.rb), +and [code-owner approval](https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/app/services/ee/protected_branches/api_service.rb). + ### Members (`USE_MEMBERS=true`, default: enabled) | Tool | Type | Description | @@ -69,6 +91,14 @@ Each tool accepts an `action` parameter selecting the specific operation. |------|------|-------------| | `browse_search` | Query | Search across GitLab | +For global or group searches, `blobs` needs Advanced Search or exact code search; +`commits`, `notes` and `wiki_blobs` need Advanced Search. Availability depends on +the instance configuration and the search target, not just its licensed tier. +When GitLab rejects one of these scopes as unavailable, the tool explains how to +use `action: "project"` with `project_id` and the same scope instead. Project +search remains available without a cross-project search backend. The tool does +not retry the request or automatically search additional projects. + ### Labels (`USE_LABELS=true`, default: enabled) | Tool | Type | Description | @@ -135,6 +165,11 @@ Each tool accepts an `action` parameter selecting the specific operation. | `browse_wiki` | Query | List and get wiki pages | | `manage_wiki` | Command | Create, update, delete wiki pages | +For `manage_wiki` updates, omit `title` to preserve the page's current slug and URL +when changing `content` or `format`, including nested pages. An explicit `title` +renames the page and can change its URL: a leaf-only title stays in the current +directory, while a title containing `/` supplies the full destination path. + ### Milestones (`USE_MILESTONE=true`, default: enabled) | Tool | Type | Description | diff --git a/packages/gitlab-mcp/eslint.config.mjs b/packages/gitlab-mcp/eslint.config.mjs index 3942f604..9f72b7f1 100644 --- a/packages/gitlab-mcp/eslint.config.mjs +++ b/packages/gitlab-mcp/eslint.config.mjs @@ -9,7 +9,7 @@ export default [ }, js.configs.recommended, { - files: ['**/*.js'], + files: ['**/*.js', 'tests/plugin/**/*.cjs'], languageOptions: { sourceType: 'commonjs', globals: { diff --git a/packages/gitlab-mcp/package.json b/packages/gitlab-mcp/package.json index 1e90c7cf..69f09223 100644 --- a/packages/gitlab-mcp/package.json +++ b/packages/gitlab-mcp/package.json @@ -545,7 +545,10 @@ "role": "original author" } ], - "bin": "./dist/src/main.js", + "bin": { + "gitlab-mcp": "./dist/src/main.js", + "gitlab-mcp-plugin": "./dist/src/plugin/main.js" + }, "exports": { "./storage-contract": { "types": "./dist/src/contracts/storage.d.ts", @@ -593,6 +596,7 @@ "test:env-gating": "node tests/scripts/test-env-gating.js", "evaluate:skills": "node scripts/evaluate-skills.mjs", "qualify:antigravity": "yarn build && node scripts/qualify-antigravity.mjs", + "qualify:codex-plugin": "yarn build && node scripts/qualify-codex-plugin.mjs", "test:help": "printf '# Test Scripts\\n yarn test - Unit tests only (fast, safe default)\\n yarn test - Unit tests matching pattern\\n yarn test:cov - Unit test coverage\\n yarn test:integration - Integration tests (needs .env.test)\\n yarn test:all - Full suite (unit + integration)\\n yarn test:env-gating - Test environment gating\\n'", "cleanup:test-groups": "node scripts/cleanup-test-groups.js", "cleanup:test-groups:dry-run": "node scripts/cleanup-test-groups.js --dry-run", @@ -646,6 +650,7 @@ "@typescript-eslint/eslint-plugin": "^8.72.0", "@typescript-eslint/parser": "^8.72.0", "auto-changelog": "^2.7.0", + "c8": "12.0.0", "cross-env": "^10.1.0", "dotenv": "^18.0.9", "eslint": "^10.13.0", diff --git a/packages/gitlab-mcp/scripts/build-codex-plugin.mjs b/packages/gitlab-mcp/scripts/build-codex-plugin.mjs new file mode 100644 index 00000000..1a798abe --- /dev/null +++ b/packages/gitlab-mcp/scripts/build-codex-plugin.mjs @@ -0,0 +1,15 @@ +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; + +// Release packaging is a repository build step, not a server command. +if (process.argv.length !== 3 || process.argv[2].startsWith('-')) + throw new Error('Usage: node scripts/build-codex-plugin.mjs '); +const require = createRequire(import.meta.url); +const { buildPlugin } = require('../dist/src/cli/plugin/build.js'); +const result = buildPlugin({ + flavor: 'private', + marketplaceZip: true, + outDir: resolve(process.argv[2]), +}); +console.log(result.zipPath); +console.log(result.checksumPath); diff --git a/packages/gitlab-mcp/scripts/build-plugin-marketplace.sh b/packages/gitlab-mcp/scripts/build-plugin-marketplace.sh index f6794588..a7f74815 100755 --- a/packages/gitlab-mcp/scripts/build-plugin-marketplace.sh +++ b/packages/gitlab-mcp/scripts/build-plugin-marketplace.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# Rebuilds the repository's plugin marketplace from the compiled CLI: .agents/plugins and -# plugins/gitlab-mcp at the repo root. The package targets a local server in HTTP mode on -# the default port; the submission ZIP the build also writes is not kept. +# Rebuilds the repository's plugin marketplace: .agents/plugins and +# plugins/gitlab-mcp at the repo root. The launcher resolves the published npm version; +# standalone ZIP builds embed production dependencies instead. # Usage: ./scripts/build-plugin-marketplace.sh (from packages/gitlab-mcp, after yarn build) set -euo pipefail -node dist/src/main.js plugin build --endpoint http://127.0.0.1:3002/mcp --out ../.. >/dev/null +node -e 'require("./dist/src/cli/plugin/build.js").buildPlugin({flavor: "private", npmRuntime: true, outDir: "../.."})' >/dev/null rm -f ../../gitlab-mcp-*.zip diff --git a/packages/gitlab-mcp/scripts/codex-plugin-lifecycle.mjs b/packages/gitlab-mcp/scripts/codex-plugin-lifecycle.mjs index 6b22a361..307c4d58 100644 --- a/packages/gitlab-mcp/scripts/codex-plugin-lifecycle.mjs +++ b/packages/gitlab-mcp/scripts/codex-plugin-lifecycle.mjs @@ -113,7 +113,7 @@ const marketplaceName = readJson(join(marketplace, '.agents', 'plugins', 'market const pluginId = `${PLUGIN_NAME}@${marketplaceName}`; const manifest = readJson(join(pluginSource, 'plugin.json')); // The package declares one server; Codex lists it under that key. -const [[SERVER_NAME, { url: endpoint }]] = Object.entries( +const [[SERVER_NAME, declaredServer]] = Object.entries( readJson(join(pluginSource, 'mcp.json')).mcpServers, ); @@ -136,8 +136,17 @@ try { // The installed copy, not the source tree, is what users run: it must match file for file. assert.deepEqual(snapshot(installed.installedPath), snapshot(pluginSource)); const server = servers().find((s) => s.name === SERVER_NAME); - assert.deepEqual(server?.transport?.type, 'streamable_http'); - assert.equal(server.transport.url, endpoint); + if (declaredServer.type === 'stdio') { + assert.equal(server?.transport?.type, 'stdio'); + assert.equal(server.transport.command, declaredServer.command); + assert( + server.transport.args.every((arg) => !arg.includes('${')), + 'plugin paths were not expanded', + ); + } else { + assert.deepEqual(server?.transport?.type, 'streamable_http'); + assert.equal(server.transport.url, declaredServer.url); + } assertUnrelatedStateKept(); step('disable and enable'); @@ -159,9 +168,10 @@ try { run('mcp', 'add', SERVER_NAME, '--', 'gitlab-mcp'); const shadowed = servers().filter((s) => s.name === SERVER_NAME); assert.equal(shadowed.length, 1, 'Codex runs both the global entry and the plugin server'); - assert.equal(shadowed[0].transport.type, 'stdio'); + assert.equal(shadowed[0].transport.command, 'gitlab-mcp', 'the plugin server was not hidden'); run('mcp', 'remove', SERVER_NAME); - assert.equal(servers().find((s) => s.name === SERVER_NAME)?.transport?.url, endpoint); + const restored = servers().find((s) => s.name === SERVER_NAME); + assert.deepEqual(restored?.transport, server.transport); step('upgrade'); const [major, minor, patch] = manifest.version.split(/[.+-]/).map(Number); diff --git a/packages/gitlab-mcp/scripts/codex-plugin-live.mjs b/packages/gitlab-mcp/scripts/codex-plugin-live.mjs new file mode 100644 index 00000000..afc2bbc1 --- /dev/null +++ b/packages/gitlab-mcp/scripts/codex-plugin-live.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:net'; +import { once } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createInterface } from 'node:readline'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { + StdioClientTransport, + getDefaultEnvironment, +} from '@modelcontextprotocol/sdk/client/stdio.js'; +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { launch } from './evaluation-process.mjs'; + +/** Read-only qualification with integration configuration, never printed. */ +export async function qualifyLiveConnections(pluginRoot) { + const url = process.env.GITLAB_API_URL; + const token = process.env.GITLAB_TOKEN; + if (!url || !token) + throw new Error('Live qualification requires GITLAB_API_URL and GITLAB_TOKEN.'); + const temporary = mkdtempSync(join(tmpdir(), 'gitlab-plugin-live-')); + const client = new Client({ name: 'gitlab-plugin-live-check', version: '1.0.0' }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(pluginRoot, 'runtime/src/plugin/main.js'), join(temporary, 'data')], + cwd: pluginRoot, + stderr: 'ignore', + }); + let http; + try { + await client.connect(transport); + const connect = async (connection, revision) => { + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'gitlab_plugin_connection', + arguments: { action: 'connect', revision, connection }, + }, + }, + ResultSchema, + ); + assert.equal(result.isError, undefined, 'Connection setup returned an error.'); + assert.equal( + result.structuredContent.state, + 'connected', + String(result.structuredContent.message), + ); + return result.structuredContent; + }; + const local = await connect({ mode: 'local', url, token, initTimeoutMs: 30000 }, 0); + const first = await client.request( + { method: 'tools/call', params: { name: 'get_profile', arguments: {} } }, + ResultSchema, + ); + assert.equal(first.isError, undefined, 'Local GitLab read failed.'); + assert.equal(typeof first.structuredContent.id, 'string'); + const listener = createServer(); + listener.listen(0, '127.0.0.1'); + await once(listener, 'listening'); + const port = listener.address().port; + await new Promise((resolve) => listener.close(resolve)); + const environment = { + ...getDefaultEnvironment(), + HOME: temporary, + USERPROFILE: temporary, + GITLAB_API_URL: url, + GITLAB_TOKEN: token, + HOST: '127.0.0.1', + PORT: String(port), + LOG_JSON: 'true', + LOG_LEVEL: 'debug', + GITLAB_INIT_TIMEOUT_MS: '30000', + }; + for (const name of ['SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS']) + if (process.env[name] !== undefined) environment[name] = process.env[name]; + http = launch(process.execPath, [join(pluginRoot, 'runtime/src/main.js')], { + cwd: join(pluginRoot, 'runtime'), + env: environment, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const lines = createInterface({ input: http.stdout }); + const diagnostics = []; + try { + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Live HTTP core did not start within 30 seconds.')), + 30000, + ); + const stop = () => { + clearTimeout(timer); + reject(new Error('Live HTTP core stopped before startup.')); + }; + http.once('exit', stop); + http.once('error', stop); + lines.on('line', (line) => { + let entry; + try { + entry = JSON.parse(line); + } catch { + return; + } + if (typeof entry.msg === 'string' && entry.msg.startsWith('Profile:')) { + const status = /status=(\d{3})/.exec(entry.msg); + const code = + /ENOTFOUND|ECONNREFUSED|ECONNRESET|CERT_[A-Z_]+|SELF_SIGNED_CERT_IN_CHAIN|UND_ERR_[A-Z_]+/.exec( + entry.msg, + ); + let message = 'GitLab profile could not be read'; + if (status) message = `GitLab HTTP ${status[1]}`; + else if (code) message = code[0]; + diagnostics.push(message); + } + if (entry.msg !== 'GitLab MCP Server running') return; + clearTimeout(timer); + http.off('exit', stop); + http.off('error', stop); + resolve(); + }); + }); + const remote = await connect( + { mode: 'remote', url: `http://127.0.0.1:${port}/mcp`, authentication: 'none' }, + local.revision, + ).catch((error) => { + throw new Error(`${error.message} ${diagnostics.join('; ')}`); + }); + const second = await client.request( + { method: 'tools/call', params: { name: 'get_profile', arguments: {} } }, + ResultSchema, + ); + assert.equal(second.isError, undefined, 'Remote GitLab read failed.'); + assert.equal( + second.structuredContent.id, + first.structuredContent.id, + 'Connection changed the GitLab account.', + ); + await client.request( + { + method: 'tools/call', + params: { + name: 'gitlab_plugin_connection', + arguments: { action: 'disconnect', revision: remote.revision }, + }, + }, + ResultSchema, + ); + return { local: 'verified', http: 'verified', sameAccount: true }; + } finally { + lines.close(); + } + } finally { + await client.close(); + if (http?.exitCode === null && http.signalCode === null) { + const closed = once(http, 'close'); + http.kill('SIGTERM'); + const timer = setTimeout(() => http.kill('SIGKILL'), 5000); + await closed; + clearTimeout(timer); + } + rmSync(temporary, { recursive: true, force: true }); + } +} diff --git a/packages/gitlab-mcp/scripts/evaluation-process.mjs b/packages/gitlab-mcp/scripts/evaluation-process.mjs index 62477ae9..9c9c265d 100644 --- a/packages/gitlab-mcp/scripts/evaluation-process.mjs +++ b/packages/gitlab-mcp/scripts/evaluation-process.mjs @@ -6,7 +6,11 @@ import { delimiter, resolve } from 'node:path'; export function resolveExecutable(name, environment = process.env, platform = process.platform) { const extensions = platform === 'win32' ? ['.exe', '.cmd', '.bat', '.com', ''] : ['']; const path = Object.entries(environment).find(([key]) => key.toUpperCase() === 'PATH')?.[1]; - for (const directory of (path ?? '').split(delimiter)) { + const directories = + name.includes('/') || (platform === 'win32' && name.includes('\\')) + ? [process.cwd()] + : (path ?? '').split(delimiter); + for (const directory of directories) { if (!directory) continue; for (const extension of extensions) { const candidate = resolve(directory, name + extension); diff --git a/packages/gitlab-mcp/scripts/plugin-test-summary.mjs b/packages/gitlab-mcp/scripts/plugin-test-summary.mjs new file mode 100644 index 00000000..ecb4d111 --- /dev/null +++ b/packages/gitlab-mcp/scripts/plugin-test-summary.mjs @@ -0,0 +1,20 @@ +import { writeFileSync } from 'node:fs'; +import { Readable } from 'node:stream'; +import { spec } from 'node:test/reporters'; + +/** Keep human output and completion evidence on one reporter pipeline. */ +export default async function* summary(source) { + const destination = process.env.PLUGIN_TEST_SUMMARY; + if (!destination) throw new Error('The qualification runner must supply its summary path.'); + async function* observe() { + for await (const event of source) { + if (event.type === 'test:summary' && event.data.file === undefined) + writeFileSync( + destination, + JSON.stringify({ success: event.data.success, counts: event.data.counts }), + ); + yield event; + } + } + yield* Readable.from(observe()).pipe(new spec()); +} diff --git a/packages/gitlab-mcp/scripts/qualify-codex-plugin.mjs b/packages/gitlab-mcp/scripts/qualify-codex-plugin.mjs new file mode 100644 index 00000000..dd18f8f9 --- /dev/null +++ b/packages/gitlab-mcp/scripts/qualify-codex-plugin.mjs @@ -0,0 +1,137 @@ +import { createRequire } from 'node:module'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { runEvaluation, resolveExecutable } from './evaluation-process.mjs'; +import { qualifyLiveConnections } from './codex-plugin-live.mjs'; + +const root = dirname(dirname(fileURLToPath(import.meta.url))); +const require = createRequire(import.meta.url); +const { buildPlugin } = require('../dist/src/cli/plugin/build.js'); +const contracts = [ + 'tests/plugin/connection.test.cjs', + 'tests/plugin/oauth.test.cjs', + 'tests/plugin/lifecycle.test.cjs', + 'tests/plugin/refresh-lock.test.cjs', + 'tests/plugin/server.test.cjs', + 'tests/plugin/bootstrap.test.cjs', + 'tests/plugin/oauth-errors.test.cjs', + 'tests/plugin/oauth-binding.test.cjs', +]; + +async function checkContracts(pluginDir, runtimeRoot, output, coverage) { + const summaryFile = join(output, coverage ? 'source-summary.json' : 'artifact-summary.json'); + const coverageFile = join(root, 'coverage/plugin.lcov.info'); + const coverageDirectory = join(output, 'coverage'); + if (coverage) mkdirSync(dirname(coverageFile), { recursive: true }); + const environment = { + ...process.env, + PLUGIN_CONTRACT_ROOT: pluginDir, + PLUGIN_RUNTIME_ROOT: runtimeRoot, + PLUGIN_TEST_SUMMARY: summaryFile, + }; + // This is an independent runner, including when qualification is invoked by a regression test. + delete environment.NODE_TEST_CONTEXT; + const result = await runEvaluation( + process.execPath, + [ + ...(coverage + ? [ + require.resolve('c8/bin/c8.js'), + '--all', + '--src=dist/src/plugin', + '--include=dist/src/plugin/*.js', + '--include=src/plugin/*.ts', + '--exclude-after-remap', + '--reporter=text', + '--reporter=lcovonly', + `--reports-dir=${coverageDirectory}`, + `--temp-directory=${join(output, 'v8')}`, + process.execPath, + ] + : []), + '--test', + '--test-reporter=./scripts/plugin-test-summary.mjs', + '--test-reporter-destination=stdout', + ...(coverage ? ['--enable-source-maps'] : []), + ...contracts, + ...(coverage ? [] : ['tests/scripts/codex-live.test.mjs']), + ], + { cwd: root, env: environment, timeoutMs: 120000 }, + ); + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + if (result.code === null) throw new Error('Plugin contract runner terminated by a signal.'); + if (result.code !== 0) return result.code; + const report = JSON.parse(readFileSync(summaryFile, 'utf8')); + assert( + report.success && report.counts.tests > 0 && report.counts.passed === report.counts.tests, + 'Plugin contracts did not all execute successfully.', + ); + if (coverage) copyFileSync(join(coverageDirectory, 'lcov.info'), coverageFile); + return 0; +} +const output = mkdtempSync(join(tmpdir(), 'gitlab-plugin-qualification-')); +try { + const codexAt = process.argv.indexOf('--codex'); + let codex; + if (codexAt !== -1) { + const requested = process.argv[codexAt + 1]; + if (!requested || requested.startsWith('--')) + throw new Error('--codex requires an executable path.'); + codex = resolveExecutable(requested); + } + const archiveAt = process.argv.indexOf('--archive'); + const suppliedArchive = process.argv[archiveAt + 1]; + if (archiveAt !== -1 && (!suppliedArchive || suppliedArchive.startsWith('--'))) + throw new Error('--archive requires a release ZIP path.'); + const artifact = + archiveAt === -1 + ? buildPlugin({ flavor: 'private', marketplaceZip: true, outDir: output, packageRoot: root }) + : { zipPath: resolve(suppliedArchive), checksumPath: resolve(suppliedArchive) + '.sha256' }; + const checksum = createHash('sha256').update(readFileSync(artifact.zipPath)).digest('hex'); + assert.equal(readFileSync(artifact.checksumPath, 'utf8'), `${checksum} gitlab-mcp-codex.zip\n`); + const marketplace = join(output, 'extracted'); + mkdirSync(marketplace); + const extraction = await runEvaluation('unzip', ['-q', artifact.zipPath, '-d', marketplace], { + cwd: root, + env: process.env, + timeoutMs: 30000, + }); + if (extraction.code !== 0) + throw new Error(`Release archive extraction failed: ${extraction.stderr}`); + const pluginDir = join(marketplace, 'plugins/gitlab-mcp'); + const coverage = process.argv.includes('--coverage'); + let code = await checkContracts(pluginDir, join(pluginDir, 'runtime/src/plugin'), output, false); + if (code === 0 && coverage) + code = await checkContracts(pluginDir, join(root, 'dist/src/plugin'), output, true); + if (code !== 0) process.exitCode = code; + else { + if (codex) { + const lifecycle = await runEvaluation( + process.execPath, + ['scripts/codex-plugin-lifecycle.mjs', '--codex', codex, marketplace], + { cwd: root, env: process.env, timeoutMs: 120000 }, + ); + process.stdout.write(lifecycle.stdout); + process.stderr.write(lifecycle.stderr); + if (lifecycle.code !== 0) throw new Error('Installed Codex plugin lifecycle failed.'); + } + const live = process.argv.includes('--live') + ? await qualifyLiveConnections(pluginDir) + : undefined; + console.log( + JSON.stringify({ + status: 'verified', + node: process.version, + sha256: checksum, + live, + }), + ); + } +} finally { + rmSync(output, { recursive: true, force: true }); +} diff --git a/packages/gitlab-mcp/skills/gitlab-setup/SKILL.md b/packages/gitlab-mcp/skills/gitlab-setup/SKILL.md index b3e909c7..0f6d0dc8 100644 --- a/packages/gitlab-mcp/skills/gitlab-setup/SKILL.md +++ b/packages/gitlab-mcp/skills/gitlab-setup/SKILL.md @@ -7,6 +7,13 @@ description: Diagnose a GitLab MCP connection, selected account, token permissio Use the installed GitLab MCP tools; their prefix depends on the host and connection name. Discover the available catalog rather than assuming a fixed prefix. Keep the user's selected account and namespace. Do not request tokens, passwords, cookies, or client secrets in conversation. +If the catalog exposes `gitlab_plugin_open_connection`, use it for initial setup, +reconnect or a requested connection change. Its private panel accepts either a GitLab +address for local core execution or an existing HTTP MCP endpoint. Wait for a verified +identity and refresh discovery before using backend tools. Never pass credentials +through chat or call the app-only connection mutation tool on the user's behalf. +The backend's settings and authorization remain authoritative after connecting. + 1. Call `manage_context` with `{"action":"show"}` to identify the host, read-only mode, preset and namespace scope. This works while GitLab is disconnected. 2. Call `manage_context` with `{"action":"whoami"}` when available to inspect the connected identity, token permissions, expiry, instance version/tier and warnings. Report only what the response proves. An unavailable diagnostic is not proof of an invalid credential. 3. Explain the concrete cause: connection failure, insufficient permission, read-only policy, disabled feature, denied action, or unsupported instance capability. Distinguish these from an empty successful query. diff --git a/packages/gitlab-mcp/src/cli-utils.ts b/packages/gitlab-mcp/src/cli-utils.ts index b27b9428..792affd3 100644 --- a/packages/gitlab-mcp/src/cli-utils.ts +++ b/packages/gitlab-mcp/src/cli-utils.ts @@ -35,10 +35,6 @@ export interface CliArgs { docker: boolean; /** Docker command arguments */ dockerArgs: string[]; - /** Run plugin command */ - plugin: boolean; - /** Plugin command arguments */ - pluginArgs: string[]; } /** @@ -59,8 +55,6 @@ export function parseCliArgs(argv: string[] = process.argv): CliArgs { installArgs: [], docker: false, dockerArgs: [], - plugin: false, - pluginArgs: [], }; // Check for subcommands (first positional arg) @@ -87,9 +81,7 @@ export function parseCliArgs(argv: string[] = process.argv): CliArgs { result.dockerArgs = args.slice(1); return result; case 'plugin': - result.plugin = true; - result.pluginArgs = args.slice(1); - return result; + throw new Error('Unknown command: plugin'); } } diff --git a/packages/gitlab-mcp/src/cli/plugin/build.ts b/packages/gitlab-mcp/src/cli/plugin/build.ts index c3fd37bd..81a46850 100644 --- a/packages/gitlab-mcp/src/cli/plugin/build.ts +++ b/packages/gitlab-mcp/src/cli/plugin/build.ts @@ -17,6 +17,7 @@ import { } from './listing'; import { PluginFlavor, validateEndpoint, validatePackage } from './validate'; import { compareCodeUnits, createZip } from './zip'; +import { collectRuntime } from './runtime'; const PACKAGE_NAME = '@structured-world/gitlab-mcp'; const PLUGIN_SCHEMA = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; @@ -36,7 +37,11 @@ const LISTING_IMAGES = [ export interface PluginBuildOptions { /** MCP endpoint the package connects to. */ - endpoint: string; + endpoint?: string; + /** Resolve the published, version-pinned npm runtime instead of embedding dependencies. */ + npmRuntime?: boolean; + /** Export an installable marketplace ZIP instead of a directory-submission ZIP. */ + marketplaceZip?: boolean; flavor: PluginFlavor; /** Directory receiving the marketplace, the plugin folder and the ZIP. */ outDir: string; @@ -52,6 +57,7 @@ export interface PluginBuildResult { marketplacePath: string; zipPath: string; sha256: string; + checksumPath?: string; files: string[]; } @@ -155,11 +161,69 @@ function mergeMarketplace(existing: Marketplace | undefined): Marketplace { return { ...existing, plugins: [...plugins, entry] }; } +/** OAuth discovery and registration come from the selected endpoint itself. */ +function connectionManifest( + options: PluginBuildOptions, + endpoint: URL | undefined, + version: string, +) { + return { + $schema: MCP_SCHEMA, + mcpServers: { + [MCP_SERVER_NAME]: endpoint + ? { type: 'streamable-http', url: endpoint.href } + : { + type: 'stdio', + command: options.npmRuntime ? 'npx' : 'node', + args: options.npmRuntime + ? [ + '-y', + '--package', + `${PACKAGE_NAME}@${version}`, + 'gitlab-mcp-plugin', + '${PLUGIN_DATA}', + ] + : ['${PLUGIN_ROOT}/runtime/src/plugin/main.js', '${PLUGIN_DATA}'], + cwd: '${PLUGIN_ROOT}', + }, + }, + }; +} + +function writeArchive(files: Map, options: PluginBuildOptions, version: string) { + const entries = [...files].map(([path, data]) => ({ + path: options.marketplaceZip ? `plugins/${PLUGIN_NAME}/${path}` : path, + data, + })); + if (options.marketplaceZip) { + // A standalone export must not include unrelated entries from the caller's catalog. + entries.push({ + path: '.agents/plugins/marketplace.json', + data: Buffer.from(`${JSON.stringify(mergeMarketplace(undefined), null, 2)}\n`), + }); + } + const zip = createZip(entries); + const zipPath = join( + options.outDir, + options.marketplaceZip ? `${PLUGIN_NAME}-codex.zip` : `${PLUGIN_NAME}-${version}.zip`, + ); + writeFileSync(zipPath, zip); + const sha256 = createHash('sha256').update(zip).digest('hex'); + const checksumPath = options.marketplaceZip ? `${zipPath}.sha256` : undefined; + if (checksumPath) writeFileSync(checksumPath, `${sha256} ${basename(zipPath)}\n`); + return { zipPath, sha256, ...(checksumPath ? { checksumPath } : {}) }; +} + export function buildPlugin(options: PluginBuildOptions): PluginBuildResult { + if (options.marketplaceZip && options.flavor === 'public') + throw new Error('Marketplace archives cannot use directory submission rules.'); const root = options.packageRoot ?? findPackageRoot(); - const endpoint = validateEndpoint(options.endpoint, options.flavor); + const endpoint = + options.endpoint === undefined ? undefined : validateEndpoint(options.endpoint, options.flavor); const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as PackageJson; if (!pkg.version) throw new Error('package.json has no version'); + if (endpoint && options.npmRuntime) + throw new Error('--npm-runtime cannot be combined with --endpoint'); // The documentation site; the npm homepage points at the repository README. const homepage = pkg.mcp?.homepage ?? pkg.homepage; const support = urlOf(pkg.bugs); @@ -193,14 +257,10 @@ export function buildPlugin(options: PluginBuildOptions): PluginBuildResult { }, }, }; - // OAuth discovery (RFC 9728) and dynamic client registration come from the - // server itself, so the package declares only where it is. - const mcp = { - $schema: MCP_SCHEMA, - mcpServers: { [MCP_SERVER_NAME]: { type: 'streamable-http', url: endpoint.href } }, - }; + const mcp = connectionManifest(options, endpoint, pkg.version); const files = collectFiles(root, manifest, mcp); + if (!endpoint && !options.npmRuntime) collectRuntime(root, files); const schema = (name: string): object => JSON.parse(readFileSync(join(root, 'plugin', 'schemas', name), 'utf8')) as object; validatePackage( @@ -225,16 +285,11 @@ export function buildPlugin(options: PluginBuildOptions): PluginBuildResult { mkdirSync(dirname(marketplacePath), { recursive: true }); writeFileSync(marketplacePath, `${JSON.stringify(marketplace, null, 2)}\n`); - const zip = createZip([...files].map(([path, data]) => ({ path, data }))); - const zipPath = join(options.outDir, `${PLUGIN_NAME}-${pkg.version}.zip`); - writeFileSync(zipPath, zip); - return { version: pkg.version, pluginDir, marketplacePath, - zipPath, - sha256: createHash('sha256').update(zip).digest('hex'), + ...writeArchive(files, options, pkg.version), files: [...files.keys()].sort(compareCodeUnits), }; } diff --git a/packages/gitlab-mcp/src/cli/plugin/command.ts b/packages/gitlab-mcp/src/cli/plugin/command.ts deleted file mode 100644 index 41dec4d1..00000000 --- a/packages/gitlab-mcp/src/cli/plugin/command.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * `gitlab-mcp plugin build`: packages the plugin for a given MCP endpoint. - */ - -import { resolve } from 'node:path'; -import { buildPlugin, PluginBuildOptions } from './build'; -import { LISTING } from './listing'; -import { PluginValidationError } from './validate'; - -export const PLUGIN_USAGE = `Usage: gitlab-mcp plugin build --endpoint [options] - -Builds the GitLab MCP plugin package for an MCP endpoint you operate. - -Options: - --endpoint MCP endpoint, e.g. https://mcp.example.com/mcp (required) - --out Output directory (default: ./gitlab-mcp-plugin) - --public Apply directory submission rules: a public HTTPS host and - the privacy and terms links are required - --privacy-url Privacy policy of the endpoint operator - --terms-url Terms of service of the endpoint operator -`; - -/** Parses `plugin build` arguments; throws with a usage hint on bad input. */ -export function parsePluginArgs(args: string[]): PluginBuildOptions { - if (args[0] !== 'build') throw new Error(PLUGIN_USAGE); - const options: Partial & { flavor: PluginBuildOptions['flavor'] } = { - flavor: 'private', - outDir: resolve('gitlab-mcp-plugin'), - }; - const valueOf = (i: number, flag: string): string => { - const value = args[i + 1]; - if (value === undefined || value.startsWith('--')) throw new Error(`${flag} needs a value`); - return value; - }; - for (let i = 1; i < args.length; i++) { - const arg = args[i]; - switch (arg) { - case '--endpoint': - options.endpoint = valueOf(i++, arg); - break; - case '--out': - options.outDir = resolve(valueOf(i++, arg)); - break; - case '--privacy-url': - options.privacyPolicyUrl = valueOf(i++, arg); - break; - case '--terms-url': - options.termsOfServiceUrl = valueOf(i++, arg); - break; - case '--public': - options.flavor = 'public'; - break; - default: - throw new Error(`Unknown option ${arg}\n\n${PLUGIN_USAGE}`); - } - } - if (!options.endpoint) throw new Error(`--endpoint is required\n\n${PLUGIN_USAGE}`); - return options as PluginBuildOptions; -} - -/** Runs the command and returns the process exit code. */ -export function runPluginCommand( - args: string[], - out: (line: string) => void = console.log, - err: (line: string) => void = console.error, -): number { - let options: PluginBuildOptions; - try { - options = parsePluginArgs(args); - } catch (error) { - err((error as Error).message); - return 2; - } - try { - const result = buildPlugin(options); - out(`Built gitlab-mcp plugin ${result.version} for ${options.endpoint}`); - out(` Plugin folder: ${result.pluginDir}`); - out(` Marketplace: ${result.marketplacePath}`); - out(` ZIP: ${result.zipPath}`); - out(` SHA-256: ${result.sha256}`); - out(''); - out( - options.flavor === 'public' - ? 'Upload the ZIP in the OpenAI platform dashboard to submit it to the Plugins Directory.' - : `Add the marketplace with: codex plugin marketplace add "${options.outDir}"\n` + - `then install ${LISTING.displayName} from the Plugins Directory.`, - ); - return 0; - } catch (error) { - err( - error instanceof PluginValidationError ? error.message : `Error: ${(error as Error).message}`, - ); - return 1; - } -} diff --git a/packages/gitlab-mcp/src/cli/plugin/index.ts b/packages/gitlab-mcp/src/cli/plugin/index.ts deleted file mode 100644 index 1bd15e8a..00000000 --- a/packages/gitlab-mcp/src/cli/plugin/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { buildPlugin, findPackageRoot } from './build'; -export type { PluginBuildOptions, PluginBuildResult } from './build'; -export { parsePluginArgs, runPluginCommand, PLUGIN_USAGE } from './command'; -export { validateEndpoint, validatePackage, PluginValidationError } from './validate'; -export type { PluginFlavor } from './validate'; diff --git a/packages/gitlab-mcp/src/cli/plugin/runtime.ts b/packages/gitlab-mcp/src/cli/plugin/runtime.ts new file mode 100644 index 00000000..c2f3325f --- /dev/null +++ b/packages/gitlab-mcp/src/cli/plugin/runtime.ts @@ -0,0 +1,87 @@ +import { createRequire } from 'node:module'; +import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { dirname, join, posix } from 'node:path'; +import { compareCodeUnits } from './zip'; + +interface RuntimePackage { + name: string; + version: string; + dependencies?: Record; +} + +/** Include installed production dependencies only, preserving conflicting versions at their consumer. */ +export function collectRuntime(root: string, files: Map): void { + const metadata = (dir: string): RuntimePackage => + JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as RuntimePackage; + const occupied = new Map(); + const dependencyDirectory = ( + requireFrom: ReturnType, + name: string, + ): string => { + // Package exports need not provide a root entry point (the MCP SDK does not). + const lookup = requireFrom.resolve.paths(name) ?? []; + const packageJson = lookup.map((dir) => join(dir, name, 'package.json')).find(existsSync); + if (!packageJson) throw new Error(`Cannot find runtime dependency ${name}`); + let dependency = dirname(packageJson); + while (!existsSync(join(dependency, 'package.json')) || metadata(dependency).name !== name) { + const next = dirname(dependency); + if (next === dependency) throw new Error(`Cannot find runtime dependency ${name}`); + dependency = next; + } + return realpathSync(dependency); + }; + const copy = (source: string, target: string): void => { + for (const entry of readdirSync(source, { withFileTypes: true })) { + const legal = /^(?:licen[sc]e|notice|copying)(?:[.-]|$)/i.test(entry.name); + if ( + ['node_modules', '.git', 'test', 'tests', 'coverage', 'docs'].includes(entry.name) || + (!legal && /\.(?:map|md|ts|mts|cts)$/i.test(entry.name)) + ) + continue; + const from = join(source, entry.name); + const to = posix.join(target, entry.name); + if (entry.isDirectory()) copy(from, to); + else if (entry.isFile()) files.set(to, readFileSync(from)); + else throw new Error(`Runtime package contains an unsupported symbolic link: ${entry.name}`); + } + }; + const dependencies = (source: string, parent: string): void => { + const requireFrom = createRequire(join(source, 'package.json')); + for (const name of Object.keys(metadata(source).dependencies ?? {}).sort(compareCodeUnits)) { + const dependency = dependencyDirectory(requireFrom, name); + let ancestor = parent; + let installed: string | undefined; + for (;;) { + installed = occupied.get(posix.join(ancestor, 'node_modules', name)); + if (installed || ancestor === 'runtime') break; + ancestor = posix.dirname(ancestor); + } + if (installed === dependency) continue; + const shared = posix.join('runtime/node_modules', name); + const target = occupied.has(shared) ? posix.join(parent, 'node_modules', name) : shared; + occupied.set(target, dependency); + copy(dependency, target); + dependencies(dependency, target); + } + }; + const compiled = join(root, 'dist', 'src'); + if (!existsSync(join(compiled, 'plugin', 'main.js'))) + throw new Error('Build the core package before building the local plugin.'); + copy(compiled, 'runtime/src'); + const version = metadata(root).version; + files.set( + 'runtime/package.json', + Buffer.from( + JSON.stringify({ + name: 'gitlab-mcp-plugin-runtime', + version, + private: true, + type: 'commonjs', + }), + ), + ); + dependencies(root, 'runtime'); + for (const name of ['LICENSE', 'NOTICE']) { + if (existsSync(join(root, name))) files.set(`runtime/${name}`, readFileSync(join(root, name))); + } +} diff --git a/packages/gitlab-mcp/src/main.ts b/packages/gitlab-mcp/src/main.ts index 69ae59ef..51365509 100644 --- a/packages/gitlab-mcp/src/main.ts +++ b/packages/gitlab-mcp/src/main.ts @@ -63,12 +63,6 @@ async function main(): Promise { } } - // Handle plugin subcommand - if (cliArgs.plugin) { - const { runPluginCommand } = await import('./cli/plugin'); - process.exit(runPluginCommand(cliArgs.pluginArgs)); - } - // Handle docker subcommand if (cliArgs.docker) { // docker init is alias for setup --mode=server diff --git a/packages/gitlab-mcp/src/plugin/authorization.ts b/packages/gitlab-mcp/src/plugin/authorization.ts new file mode 100644 index 00000000..741bf075 --- /dev/null +++ b/packages/gitlab-mcp/src/plugin/authorization.ts @@ -0,0 +1,255 @@ +import { randomBytes } from 'node:crypto'; +import { createServer, type Server } from 'node:http'; +import type { + OAuthClientProvider, + OAuthDiscoveryState, +} from '@modelcontextprotocol/sdk/client/auth.js'; +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { RemoteAuthorization } from './connection-store'; +import { SavedOAuthTokens } from './connection-store'; +import { OAuthTokensSchema } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { fetchWithinOrigin, type FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { RefreshGuard } from './refresh-guard'; + +interface SharedAuthorization { + current(): RemoteAuthorization | undefined; + lockPath: string; +} + +/** SDK OAuth state belongs to one proposed or committed connection, never to a host name. */ +export class ConnectionAuthorization implements OAuthClientProvider { + private verifier?: string; + private readonly nonce = randomBytes(32).toString('base64url'); + private callback?: Server; + private timer?: NodeJS.Timeout; + authorizationUrl?: string; + onCode?: (code: string) => Promise; + onFailure?: () => void; + interactive = false; + private readonly abort = new AbortController(); + private readonly refreshLock: RefreshGuard; + + constructor( + public data: RemoteAuthorization, + private readonly save: (patch: Partial) => RemoteAuthorization | void, + private readonly shared: SharedAuthorization, + ) { + this.refreshLock = new RefreshGuard(shared.lockPath); + } + + readonly fetch: FetchLike = async (url, init) => { + const body = init?.body; + const signal = init?.signal + ? AbortSignal.any([init.signal, this.abort.signal]) + : this.abort.signal; + const request = { ...init, signal }; + if ( + body instanceof URLSearchParams && + body.get('grant_type') === 'refresh_token' && + this.shared.current() + ) { + const previous = this.data.tokens; + return this.refreshLock.run(() => this.refresh(url, request, body, previous)); + } + return fetch(url, request); + }; + + private async refresh( + url: string | URL, + init: RequestInit, + body: URLSearchParams, + previous: OAuthTokens | undefined, + ): Promise { + const current = this.shared.current(); + if (!current) throw new Error('The OAuth connection is no longer selected.'); + this.data = current; + const tokens = current.tokens; + if (!tokens) throw new Error('Reconnect from connection settings.'); + if ( + tokens.refresh_token !== body.get('refresh_token') || + tokens.access_token !== previous?.access_token + ) + return Response.json(tokens); + const issuer = tokens.issuer; + const response = await fetchWithinOrigin(fetch)(url, init); + const value: unknown = await response.json(); + this.abort.signal.throwIfAborted(); + if (!response.ok) return this.refreshError(response.status, value); + // RFC 6749 section 6 retains the old refresh token when the response omits a replacement. + const refreshed = { + refresh_token: tokens.refresh_token, + ...OAuthTokensSchema.omit({ issuer: true }).parse(value), + issuer, + }; + this.persist({ tokens: refreshed }); + return Response.json(refreshed); + } + + private refreshError(status: number, value: unknown): Response { + const code = + typeof value === 'object' && value !== null && 'error' in value ? value.error : undefined; + if (code === 'invalid_grant') this.persist({ tokens: undefined }); + else if (code === 'invalid_client' || code === 'unauthorized_client') + this.persist({ client: undefined, tokens: undefined, discovery: undefined }); + const recognized = [ + 'invalid_grant', + 'invalid_client', + 'unauthorized_client', + 'invalid_request', + 'invalid_scope', + 'unsupported_grant_type', + 'server_error', + 'temporarily_unavailable', + ]; + if (typeof code !== 'string' || !recognized.includes(code)) + throw new Error(`OAuth refresh failed (HTTP ${status}).`); + return Response.json({ error: code }, { status }); + } + + get redirectUrl(): string { + return this.data.redirectUrl; + } + get clientMetadata(): OAuthClientMetadata { + return { + client_name: 'GitLab by Structured World', + redirect_uris: [this.redirectUrl], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }; + } + state(): string { + return this.nonce; + } + clientInformation(): OAuthClientInformationMixed | undefined { + return this.data.client; + } + saveClientInformation(client: OAuthClientInformationMixed): void { + this.persist({ client }); + } + tokens(): OAuthTokens | undefined { + return this.data.tokens; + } + saveTokens(tokens: OAuthTokens): void { + // Committed refresh results are saved under the shared lock before the SDK receives them. + const current = this.shared.current(); + if (current) { + this.data = current; + return; + } + this.persist({ tokens: SavedOAuthTokens.parse(tokens) }); + } + saveDiscoveryState(discovery: OAuthDiscoveryState): void { + this.persist({ discovery }); + } + discoveryState(): OAuthDiscoveryState | undefined { + return this.data.discovery; + } + saveCodeVerifier(verifier: string): void { + this.verifier = verifier; + } + codeVerifier(): string { + if (!this.verifier) throw new Error('Sign-in expired. Reconnect from connection settings.'); + return this.verifier; + } + redirectToAuthorization(url: URL): void { + if (!this.interactive) throw new Error('Reconnect from connection settings.'); + this.authorizationUrl = url.href; + } + invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery'): void { + const current = this.shared.current(); + if (current && (scope === 'all' || scope === 'tokens')) { + this.data = current; + if (scope === 'all') this.verifier = undefined; + return; + } + const patch: Partial = {}; + if (scope === 'all' || scope === 'client') patch.client = undefined; + if (scope === 'all' || scope === 'tokens') patch.tokens = undefined; + if (scope === 'all' || scope === 'discovery') patch.discovery = undefined; + if (scope === 'all' || scope === 'verifier') this.verifier = undefined; + this.persist(patch); + } + + private persist(patch: Partial): void { + this.data = { ...this.data, ...patch }; + const committed = this.save(patch); + if (committed) this.data = committed; + } + + async listen(): Promise { + const path = `/callback/${randomBytes(24).toString('base64url')}`; + const callback = createServer((request, response) => { + if (!request.url) { + response.writeHead(400).end('Invalid authorization callback.'); + return; + } + const url = new URL(request.url, this.redirectUrl); + if ( + request.method !== 'GET' || + url.pathname !== path || + url.searchParams.get('state') !== this.nonce + ) { + response.writeHead(400).end('Invalid authorization callback.'); + return; + } + const issuer = this.data.discovery?.authorizationServerUrl; + if (url.searchParams.has('iss') && url.searchParams.get('iss') !== String(issuer)) { + response.writeHead(400).end('Invalid authorization issuer.'); + return; + } + const code = url.searchParams.get('code'); + response + .writeHead(200, { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': "default-src 'none'", + }) + .end('Return to GitLab connection settings.'); + this.stopListener(); + if (!code || url.searchParams.has('error')) { + this.close(); + this.onFailure?.(); + return; + } + void this.onCode?.(code).catch(() => this.onFailure?.()); + }); + this.callback = callback; + await new Promise((resolve, reject) => { + callback.once('error', reject); + callback.listen(0, '127.0.0.1', resolve); + }); + const address = callback.address(); + if (!address || typeof address === 'string') throw new Error('Cannot start sign-in callback.'); + this.data.redirectUrl = `http://127.0.0.1:${address.port}${path}`; + this.interactive = true; + this.timer = setTimeout(() => { + this.close(); + this.onFailure?.(); + }, 5 * 60_000); + this.timer.unref(); + } + + close(): void { + this.stopListener(); + this.verifier = undefined; + this.interactive = false; + } + + async dispose(): Promise { + this.close(); + this.abort.abort(); + await this.refreshLock.close(); + } + + private stopListener(): void { + if (this.timer) clearTimeout(this.timer); + this.callback?.close(); + this.callback?.closeAllConnections(); + this.callback = undefined; + } +} diff --git a/packages/gitlab-mcp/src/plugin/connection-store.ts b/packages/gitlab-mcp/src/plugin/connection-store.ts new file mode 100644 index 00000000..3dbe3f33 --- /dev/null +++ b/packages/gitlab-mcp/src/plugin/connection-store.ts @@ -0,0 +1,204 @@ +import { mkdirSync, openSync, closeSync, chmodSync, watch, type FSWatcher } from 'node:fs'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { z } from 'zod'; +import type { OAuthDiscoveryState } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { OAuthClientInformationMixed } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { + OAuthClientInformationSchema, + OAuthClientInformationFullSchema, + OAuthTokensSchema, + OAuthMetadataSchema, + OAuthProtectedResourceMetadataSchema, +} from '@modelcontextprotocol/sdk/shared/auth.js'; + +// The SDK stamps saved tokens with their issuer; shared cache updates must retain that binding. +export const SavedOAuthTokens = OAuthTokensSchema.extend({ issuer: z.string().min(1) }); + +export interface RemoteAuthorization { + client?: OAuthClientInformationMixed; + tokens?: z.infer; + discovery?: OAuthDiscoveryState; + redirectUrl: string; +} + +const address = z + .string() + .max(4096) + .refine((raw) => { + try { + const url = new URL(raw); + return ( + ['http:', 'https:'].includes(url.protocol) && + !url.username && + !url.password && + !url.search && + !url.hash + ); + } catch { + return false; + } + }, 'Use an HTTP or HTTPS address without credentials, query or fragment'); + +export const ConnectionInput = z.discriminatedUnion('mode', [ + z + .object({ + mode: z.literal('local'), + url: address, + token: z.string().min(1).max(16384), + initTimeoutMs: z.number().int().min(1000).max(45000).optional(), + }) + .strict(), + z + .object({ + mode: z.literal('remote'), + url: address, + authentication: z.enum(['oauth', 'bearer', 'none']), + token: z.string().min(1).max(16384).optional(), + }) + .strict(), +]); + +export type Connection = z.infer & { authorization?: RemoteAuthorization }; +export interface ConnectionSnapshot { + revision: number; + connection: Connection | null; +} + +const SavedConnection = z + .discriminatedUnion('mode', [ + ConnectionInput.options[0], + ConnectionInput.options[1].extend({ + authorization: z + .object({ + redirectUrl: z.url(), + client: z + .union([OAuthClientInformationFullSchema, OAuthClientInformationSchema]) + .optional(), + tokens: SavedOAuthTokens.optional(), + discovery: z + .object({ + authorizationServerUrl: z.url(), + authorizationServerMetadata: OAuthMetadataSchema.optional(), + resourceMetadata: OAuthProtectedResourceMetadataSchema.optional(), + resourceMetadataUrl: z.url().optional(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), + }), + ]) + .refine((connection) => { + if (connection.mode === 'local') return true; + if (connection.authentication === 'oauth') return connection.authorization !== undefined; + if (connection.authorization !== undefined) return false; + return connection.authentication !== 'bearer' || connection.token !== undefined; + }); + +export class InvalidSavedConnection extends Error { + constructor(readonly revision: number) { + super('The saved connection data is invalid. Configure the connection again.'); + } +} + +/** One private, transactional selection shared by all plugin processes of this user. + * The host supplies its PLUGIN_DATA directory through launcher configuration, not tool arguments. + */ +export class ConnectionStore { + private readonly db: DatabaseSync; + + constructor(private readonly dir: string) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + chmodSync(dir, 0o700); + const path = join(dir, 'connection.sqlite'); + closeSync(openSync(path, 'a', 0o600)); + chmodSync(path, 0o600); + this.db = new DatabaseSync(path, { timeout: 5000 }); + // DELETE journaling keeps no old credential history after a committed disconnect. + this.db.exec( + 'PRAGMA secure_delete=ON; PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS connection (id INTEGER PRIMARY KEY CHECK(id=1), revision INTEGER NOT NULL, value TEXT); INSERT OR IGNORE INTO connection VALUES(1,0,NULL)', + ); + } + + read(): ConnectionSnapshot { + const row = this.db.prepare('SELECT revision, value FROM connection WHERE id=1').get(); + if (!row) throw new Error('The saved connection record is missing.'); + const revision = Number(row.revision); + try { + return { + revision, + connection: + row.value === null ? null : SavedConnection.parse(JSON.parse(String(row.value))), + }; + } catch { + // Parser diagnostics may contain credentials from the damaged record. + throw new InvalidSavedConnection(revision); + } + } + + revision(): number { + const row = this.db.prepare('SELECT revision FROM connection WHERE id=1').get(); + if (!row) throw new Error('The saved connection record is missing.'); + return Number(row.revision); + } + + get refreshLockPath(): string { + return join(this.dir, 'oauth-refresh.sqlite'); + } + + readAuthorization(expected: number): RemoteAuthorization { + return this.authorized(expected).authorization; + } + + private authorized(expected: number) { + const saved = this.read(); + if ( + saved.revision !== expected || + saved.connection?.mode !== 'remote' || + saved.connection.authentication !== 'oauth' || + !saved.connection.authorization + ) + throw new Error('The connection changed. Open settings again.'); + return { connection: saved.connection, authorization: saved.connection.authorization }; + } + + commit(expected: number, connection: Connection | null): ConnectionSnapshot { + const result = this.db + .prepare('UPDATE connection SET revision=revision+1,value=? WHERE id=1 AND revision=?') + .run(connection === null ? null : JSON.stringify(connection), expected); + if (result.changes !== 1) + throw new Error('The connection changed in another window. Open settings again.'); + return { revision: expected + 1, connection }; + } + + updateAuthorization(expected: number, patch: Partial): RemoteAuthorization { + this.db.exec('BEGIN IMMEDIATE'); + try { + const { connection, authorization: previous } = this.authorized(expected); + const authorization = { ...previous, ...patch }; + this.db.prepare('UPDATE connection SET value=? WHERE id=1').run( + JSON.stringify({ + ...connection, + authorization, + }), + ); + this.db.exec('COMMIT'); + return authorization; + } catch (error) { + this.db.exec('ROLLBACK'); + throw error; + } + } + + close(): void { + this.db.close(); + } + + watchChanges(changed: () => void): FSWatcher { + return watch(this.dir, (_event, name) => { + if (name === null || String(name) === 'connection.sqlite') changed(); + }); + } +} diff --git a/packages/gitlab-mcp/src/plugin/connections.ts b/packages/gitlab-mcp/src/plugin/connections.ts new file mode 100644 index 00000000..aefd6df7 --- /dev/null +++ b/packages/gitlab-mcp/src/plugin/connections.ts @@ -0,0 +1,495 @@ +import { join } from 'node:path'; +import type { FSWatcher } from 'node:fs'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js'; +import { ResultSchema, type ClientCapabilities } from '@modelcontextprotocol/sdk/types.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { DEFAULT_REQUEST_TIMEOUT_MSEC } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { ConnectionAuthorization } from './authorization'; +import { + ConnectionInput, + ConnectionStore, + InvalidSavedConnection, + type Connection, + type ConnectionSnapshot, +} from './connection-store'; + +export interface Backend { + client: Client; + transport: Transport; + snapshot: ConnectionSnapshot; + authorization?: ConnectionAuthorization; + identity?: unknown; + stage?: 'initialization' | 'tool discovery' | 'account verification'; +} + +function connectionFailure(error: unknown, stage: Backend['stage']): string { + const text = + error instanceof Error + ? `${error.message} ${String((error as Error & { cause?: { code?: string } }).cause?.code ?? '')}` + : ''; + let reason = 'Check the address, credentials and server availability.'; + if (/401|unauthorized|authentication/i.test(text)) + reason = 'Authentication was rejected. Check the token or reconnect to sign in.'; + else if (/403|forbidden|scope/i.test(text)) + reason = 'The account lacks permission. Check its GitLab token scopes.'; + else if (/ENOTFOUND|EAI_AGAIN/i.test(text)) + reason = 'The address could not be resolved. Check the hostname and network.'; + else if (/certificate|CERT_|TLS|SSL/i.test(text)) + reason = + 'The TLS certificate could not be verified. Check the certificate chain and configured CA.'; + else if (/ECONNREFUSED|ECONNRESET|unreachable|timed out|timeout/i.test(text)) + reason = 'The server is unreachable or did not answer. Check the address and network.'; + return `Connection failed during ${stage ?? 'setup'}. ${reason} The previous connection is unchanged.`; +} + +function localTransport( + connection: Extract, + core: string, +): Transport { + // A fresh process never inherits another server's policy or credentials. + const env: Record = { + GITLAB_API_URL: connection.url, + GITLAB_TOKEN: connection.token, + LOG_JSON: 'true', + }; + if (connection.initTimeoutMs !== undefined) + env.GITLAB_INIT_TIMEOUT_MS = String(connection.initTimeoutMs); + for (const name of [ + 'HOME', + 'USERPROFILE', + 'PATH', + 'SystemRoot', + 'TEMP', + 'TMP', + 'LANG', + 'SSL_CERT_FILE', + 'NODE_EXTRA_CA_CERTS', + 'GITLAB_CA_CERT_PATH', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + ]) { + const value = process.env[name]; + if (value !== undefined) env[name] = value; + } + return new StdioClientTransport({ + command: process.execPath, + args: [core, 'stdio'], + env, + cwd: join(core, '..', '..', '..'), + stderr: 'ignore', + }); +} + +/** The previous backend remains selected until a replacement has passed discovery and identity. */ +export class Connections { + active?: Backend; + private pending?: Backend; + private epoch = 0; + private opening?: Promise; + private restoring?: Backend; + private failedSnapshot?: ConnectionSnapshot; + private readonly watcher: FSWatcher; + private closing = false; + private phase: 'unconfigured' | 'connecting' | 'connected' | 'failed' = 'unconfigured'; + private message?: { revision: number | null; text: string }; + private identity?: unknown; + capabilities: ClientCapabilities = {}; + wire: (backend: Backend) => void = () => {}; + changed: () => void = () => {}; + + constructor( + readonly store: ConnectionStore, + private readonly core = join(__dirname, 'core.js'), + ) { + this.watcher = store.watchChanges(() => { + if (this.closing) return; + void this.refresh().catch(() => { + this.phase = 'failed'; + this.message = { + revision: null, + text: 'Could not read the saved connection. Reopen connection settings.', + }; + this.changed(); + }); + }); + } + + private async refresh(): Promise { + const revision = this.store.revision(); + if (this.pending && this.pending.snapshot.revision !== revision) await this.cancel(); + if (this.closing) return; + await this.ready(); + } + + status(): Record { + const saved = this.selection(); + const active = this.active?.snapshot.revision === saved.revision; + const message = + this.phase !== 'connecting' && + (this.message?.revision === null || this.message?.revision === saved.revision) + ? this.message.text + : undefined; + let state = this.phase; + if (state === 'failed' && !message) { + if (active) state = 'connected'; + else if (saved.connection) state = 'connecting'; + else state = 'unconfigured'; + } + return { + revision: saved.revision, + state: saved.error ? 'failed' : state, + mode: saved.connection?.mode ?? null, + url: saved.connection?.url ?? null, + authentication: saved.connection?.mode === 'remote' ? saved.connection.authentication : null, + initTimeoutMs: + saved.connection?.mode === 'local' ? (saved.connection.initTimeoutMs ?? null) : null, + identity: !saved.error && active ? (this.identity ?? null) : null, + message: saved.error ?? message ?? null, + authorizationUrl: this.pending?.authorization?.authorizationUrl ?? null, + proposedTarget: this.pending + ? { + mode: this.pending.snapshot.connection?.mode, + url: this.pending.snapshot.connection?.url, + } + : null, + }; + } + + private failed(saved: ConnectionSnapshot): boolean { + return ( + this.failedSnapshot?.revision === saved.revision && + JSON.stringify(this.failedSnapshot.connection) === JSON.stringify(saved.connection) + ); + } + + private selection(): ConnectionSnapshot & { error?: string } { + try { + return this.store.read(); + } catch (error) { + if (!(error instanceof InvalidSavedConnection)) throw error; + return { revision: error.revision, connection: null, error: error.message }; + } + } + + async ready(): Promise { + if (this.closing) return undefined; + const saved = this.selection(); + if (saved.error) { + if (this.failedSnapshot?.revision !== saved.revision || this.active) { + await this.dispose(this.active); + this.active = undefined; + this.identity = undefined; + this.phase = 'failed'; + this.message = { revision: saved.revision, text: saved.error }; + this.failedSnapshot = saved; + this.changed(); + } + return undefined; + } + if (!saved.connection && !this.active) return undefined; + if (this.active?.snapshot.revision === saved.revision) { + if (this.active.authorization && saved.connection?.authorization) + this.active.authorization.data = saved.connection.authorization; + this.active.snapshot = saved; + return this.active; + } + if (this.failed(saved)) return undefined; + this.opening ??= this.restore(saved).finally(() => { + this.opening = undefined; + }); + await this.opening; + if (this.closing) return undefined; + const current = this.selection(); + if ( + this.active?.snapshot.revision !== current.revision && + !this.failed(current) && + current.connection + ) + return this.ready(); + return this.active; + } + + private async restore(saved: ConnectionSnapshot): Promise { + const epoch = this.epoch; + const previous = this.active; + await this.dispose(previous); + if (this.closing || epoch !== this.epoch || this.active !== previous) return; + this.active = undefined; + this.identity = undefined; + if (!saved.connection) { + this.phase = 'unconfigured'; + this.changed(); + return; + } + let backend: Backend | undefined; + try { + backend = await this.make(saved, false); + this.restoring = backend; + if (this.closing || epoch !== this.epoch) { + await this.dispose(backend); + return; + } + await this.qualify(backend); + if (epoch !== this.epoch || this.store.read().revision !== backend.snapshot.revision) { + await this.dispose(backend); + return; + } + this.active = backend; + this.identity = backend.identity; + this.phase = 'connected'; + this.message = undefined; + } catch (error) { + await this.dispose(backend); + if (epoch !== this.epoch || this.closing) return; + this.phase = 'failed'; + const current = this.selection(); + this.failedSnapshot = current.revision === saved.revision ? current : saved; + this.message = { revision: saved.revision, text: connectionFailure(error, backend?.stage) }; + } finally { + if (this.restoring === backend) this.restoring = undefined; + } + this.changed(); + } + + async connect(input: unknown, expected: number): Promise> { + const connection = ConnectionInput.parse(input); + if (connection.mode === 'remote' && connection.authentication === 'bearer' && !connection.token) + throw new Error('Enter the bearer token.'); + if (this.store.revision() !== expected) + throw new Error('The connection changed. Open settings again.'); + const epoch = ++this.epoch; + const pending = this.pending; + this.pending = undefined; + await this.dispose(pending); + this.phase = 'connecting'; + let backend: Backend | undefined; + try { + backend = await this.make({ revision: expected, connection }, true); + if (epoch !== this.epoch) { + await this.dispose(backend); + return this.status(); + } + this.pending = backend; + await this.qualify(backend); + await this.accept(backend, epoch); + } catch (error) { + if (error instanceof UnauthorizedError && backend?.authorization?.authorizationUrl) + return this.status(); + await this.dispose(backend); + if (epoch === this.epoch) { + this.pending = undefined; + this.phase = 'failed'; + this.message = { revision: expected, text: connectionFailure(error, backend?.stage) }; + } + } + return this.status(); + } + + private authorization( + connection: Extract, + interactive: boolean, + getBackend: () => Backend, + ): ConnectionAuthorization { + const data = interactive ? { redirectUrl: '' } : connection.authorization; + if (!data) throw new Error('Reconnect to sign in.'); + const selected = (): boolean => !interactive || this.active === getBackend(); + const authorization = new ConnectionAuthorization( + data, + (patch) => { + connection.authorization = authorization.data; + if (!selected()) return; + const backend = getBackend(); + const committed = this.store.updateAuthorization(backend.snapshot.revision, patch); + backend.snapshot.connection = { ...connection, authorization: committed }; + return committed; + }, + { + current: () => { + if (!selected()) return undefined; + return this.store.readAuthorization(getBackend().snapshot.revision); + }, + lockPath: this.store.refreshLockPath, + }, + ); + connection.authorization = data; + authorization.onFailure = () => { + if (this.pending !== getBackend()) return; + const failedEpoch = this.epoch + 1; + void this.cancel().then(() => { + if (this.epoch !== failedEpoch || this.closing) return; + this.phase = 'failed'; + this.message = { + revision: getBackend().snapshot.revision, + text: 'Sign-in was cancelled, denied or expired. Try again.', + }; + this.changed(); + }); + }; + return authorization; + } + + private async make(snapshot: ConnectionSnapshot, interactive: boolean): Promise { + const connection = snapshot.connection; + if (!connection) throw new Error('No connection is selected.'); + const client = new Client( + { name: 'gitlab-mcp-plugin', version: '1.0.0' }, + { capabilities: this.capabilities }, + ); + let backend: Backend; + let transport: Transport; + let provider: ConnectionAuthorization | undefined; + if (connection.mode === 'local') { + transport = localTransport(connection, this.core); + } else { + if (connection.authentication === 'oauth') + provider = this.authorization(connection, interactive, () => backend); + transport = new StreamableHTTPClientTransport(new URL(connection.url), { + authProvider: provider, + fetch: provider?.fetch, + ...(connection.authentication === 'bearer' + ? { requestInit: { headers: { Authorization: `Bearer ${connection.token}` } } } + : {}), + }); + } + backend = { client, snapshot, transport, authorization: provider }; + try { + this.wire(backend); + this.wireAuthorization(backend, connection.url); + if (interactive && provider) await provider.listen(); + return backend; + } catch (error) { + await this.dispose(backend); + throw error; + } + } + + private wireAuthorization(backend: Backend, url: string): void { + if (!backend.authorization) return; + backend.authorization.onCode = async (code) => { + if (this.pending !== backend) return; + const epoch = this.epoch; + try { + await (backend.transport as StreamableHTTPClientTransport).finishAuth(code); + // SDK transports are single-use: failed initialization closes the first transport. + backend.transport = new StreamableHTTPClientTransport(new URL(url), { + authProvider: backend.authorization, + fetch: backend.authorization?.fetch, + }); + await this.qualify(backend); + await this.accept(backend, epoch); + } catch { + backend.authorization?.onFailure?.(); + } + }; + } + + private async qualify(backend: Backend): Promise { + // One SDK request budget covers the whole handshake, including arbitrarily paginated discovery. + const signal = AbortSignal.timeout(DEFAULT_REQUEST_TIMEOUT_MSEC); + backend.stage = 'initialization'; + await backend.client.connect(backend.transport, { signal }); + backend.stage = 'tool discovery'; + let cursor: string | undefined; + for (;;) { + const page = await backend.client.listTools({ cursor }, { signal }); + if (page.tools.some((tool) => tool.name === 'get_profile')) break; + // MCP pagination ends only when nextCursor is absent; cursors are opaque strings. + // https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination + if (page.nextCursor === undefined) + throw new Error('This endpoint is not a supported GitLab MCP server.'); + cursor = page.nextCursor; + } + backend.stage = 'account verification'; + const profile = await backend.client.request( + { method: 'tools/call', params: { name: 'get_profile', arguments: {} } }, + ResultSchema, + { signal }, + ); + if (profile.isError) { + if (JSON.stringify(profile).includes('CONNECTION_FAILED')) + throw new Error('Backend initialization failed or timed out.'); + const challenge = JSON.stringify(profile._meta?.['mcp/www_authenticate']); + if (challenge?.includes('invalid_token')) + throw new Error('GitLab authentication was rejected.'); + if (challenge?.includes('insufficient_scope')) + throw new Error('GitLab token scope is insufficient.'); + throw new Error('The backend could not verify the GitLab account.'); + } + const identity = profile.structuredContent; + if ( + typeof identity !== 'object' || + identity === null || + !('id' in identity) || + typeof identity.id !== 'string' || + identity.id.length === 0 + ) + throw new Error('The server did not identify the connected account.'); + backend.identity = identity; + } + + private async accept(backend: Backend, epoch: number): Promise { + if (this.pending !== backend || epoch !== this.epoch) { + await this.dispose(backend); + return; + } + backend.snapshot = this.store.commit(backend.snapshot.revision, backend.snapshot.connection); + const previous = this.active; + this.active = backend; + this.identity = backend.identity; + this.failedSnapshot = undefined; + this.pending = undefined; + backend.authorization?.close(); + this.phase = 'connected'; + this.message = undefined; + this.changed(); + await this.dispose(previous); + } + + async cancel(): Promise { + this.epoch++; + const pending = this.pending; + this.pending = undefined; + await this.dispose(pending); + const saved = this.selection(); + if (this.active?.snapshot.revision === saved.revision) this.phase = 'connected'; + else if (this.failed(saved)) this.phase = 'failed'; + else this.phase = saved.connection ? 'connecting' : 'unconfigured'; + if (this.phase !== 'failed') this.message = undefined; + } + + async disconnect(expected: number): Promise { + this.store.commit(expected, null); + await this.cancel(); + const active = this.active; + this.active = undefined; + this.identity = undefined; + this.phase = 'unconfigured'; + this.message = undefined; + this.changed(); + await this.dispose(active); + } + + async close(): Promise { + if (this.closing) return; + this.closing = true; + this.watcher.close(); + await this.cancel(); + await this.dispose(this.restoring); + await this.opening; + await this.dispose(this.active); + this.active = undefined; + this.store.close(); + } + + private async dispose(backend: Backend | undefined): Promise { + await backend?.authorization?.dispose(); + if (backend) await backend.client.close(); + } +} diff --git a/packages/gitlab-mcp/src/plugin/core.ts b/packages/gitlab-mcp/src/plugin/core.ts new file mode 100644 index 00000000..5d5e0d71 --- /dev/null +++ b/packages/gitlab-mcp/src/plugin/core.ts @@ -0,0 +1,10 @@ +import { startServer, gracefulShutdown } from '../server'; + +// The connector supplies this process's configuration; standalone CLI profiles do not select its target. +process.stdin.once('end', () => { + void gracefulShutdown('stdin closed').catch(() => process.exit(1)); +}); +void startServer().catch(() => { + console.error('The local GitLab core could not start.'); + process.exit(1); +}); diff --git a/packages/gitlab-mcp/src/plugin/main.ts b/packages/gitlab-mcp/src/plugin/main.ts new file mode 100644 index 00000000..3c198adf --- /dev/null +++ b/packages/gitlab-mcp/src/plugin/main.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env node + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { ConnectionStore } from './connection-store'; +import { Connections } from './connections'; +import { createPluginServer } from './server'; + +async function main(): Promise { + const dir = process.argv[2]; + if (!dir || dir.includes('${')) + throw new Error('The host must provide the plugin data directory.'); + const connections = new Connections(new ConnectionStore(dir)); + const server = createPluginServer(connections); + let closing = false; + const close = async (): Promise => { + if (closing) return; + closing = true; + await connections.close(); + await server.close(); + }; + server.onclose = () => { + void close(); + }; + process.stdin.once('end', () => { + void close(); + }); + for (const signal of ['SIGINT', 'SIGTERM'] as const) + process.once(signal, () => { + void close(); + }); + await server.connect(new StdioServerTransport()); +} +void main().catch(() => { + console.error('GitLab plugin could not start. Check Node.js 24+ and the plugin data directory.'); + process.exitCode = 1; +}); diff --git a/packages/gitlab-mcp/src/plugin/panel.ts b/packages/gitlab-mcp/src/plugin/panel.ts new file mode 100644 index 00000000..5e1bea40 --- /dev/null +++ b/packages/gitlab-mcp/src/plugin/panel.ts @@ -0,0 +1,35 @@ +export const CONNECTION_PANEL_URI = 'ui://gitlab-mcp-local/connection.html'; +export const CONNECTION_PANEL_META = { + ui: { csp: { connectDomains: [], resourceDomains: [], frameDomains: [] }, prefersBorder: true }, +}; + +/** Credentials travel through the host's app bridge, never through conversation text. */ +export function connectionPanel(): string { + return String.raw` +

Your GitLab connection

Choose where GitLab runs. Change this later without reinstalling the plugin.

+
Loading connection...
+
+The core server starts automatically. GitLab 16.0 or later. + +
Stored privately on this computer. Never paste credentials into the chat.
+
Local connection optionsFor slow instances, increase the initial discovery budget. Maximum 45000 leaves time for the host to receive the result.
+
+`; +} diff --git a/packages/gitlab-mcp/src/plugin/refresh-guard.ts b/packages/gitlab-mcp/src/plugin/refresh-guard.ts new file mode 100644 index 00000000..886edb0e --- /dev/null +++ b/packages/gitlab-mcp/src/plugin/refresh-guard.ts @@ -0,0 +1,62 @@ +import { DatabaseSync } from 'node:sqlite'; +import { openSync, closeSync } from 'node:fs'; +import { setTimeout } from 'node:timers/promises'; +import { performance } from 'node:perf_hooks'; +import { DEFAULT_REQUEST_TIMEOUT_MSEC } from '@modelcontextprotocol/sdk/shared/protocol.js'; + +const SQLITE_BUSY = 5; + +/** An empty database supplies an OS-owned mutex, independently of connection state writes. */ +export class RefreshGuard { + private readonly db: DatabaseSync; + private tail: Promise = Promise.resolve(); + private closing = false; + + constructor(path: string) { + closeSync(openSync(path, 'a', 0o600)); + this.db = new DatabaseSync(path, { timeout: 0 }); + } + + run(operation: () => Promise): Promise { + const result = this.tail.then(async () => { + await this.acquire(); + try { + return await operation(); + } finally { + if (!this.closing) this.db.exec('ROLLBACK'); + } + }); + this.tail = result.then( + () => {}, + () => {}, + ); + return result; + } + + private async acquire(): Promise { + const deadline = performance.now() + DEFAULT_REQUEST_TIMEOUT_MSEC; + while (!this.closing) { + try { + this.db.exec('BEGIN IMMEDIATE'); + return; + } catch (error) { + if ( + !(error instanceof Error) || + !('errcode' in error) || + error.errcode !== SQLITE_BUSY || + performance.now() >= deadline + ) + throw new Error('Could not acquire the shared OAuth refresh lock.', { cause: error }); + await setTimeout(10); + } + } + throw new Error('OAuth connection closed.'); + } + + async close(): Promise { + if (this.closing) return; + this.closing = true; + this.db.close(); + await this.tail; + } +} diff --git a/packages/gitlab-mcp/src/plugin/server.ts b/packages/gitlab-mcp/src/plugin/server.ts new file mode 100644 index 00000000..45346637 --- /dev/null +++ b/packages/gitlab-mcp/src/plugin/server.ts @@ -0,0 +1,351 @@ +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { z } from 'zod'; +import { + ResultSchema, + ErrorCode, + McpError, + SetLevelRequestSchema, + type Request, + type Notification, + type Result, + type Tool, +} from '@modelcontextprotocol/sdk/types.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { Connections, type Backend } from './connections'; +import { connectionPanel, CONNECTION_PANEL_META, CONNECTION_PANEL_URI } from './panel'; +import { MCP_APPS_EXTENSION } from '../mcp-apps'; + +const PREFIX = 'gitlab_plugin_'; +const EMPTY_INPUT = { type: 'object' as const, properties: {}, additionalProperties: false }; +const OBJECT_OUTPUT = { type: 'object' as const, additionalProperties: true }; +const ConnectionAction = z.object({ + action: z.enum(['status', 'connect', 'reconnect', 'cancel', 'disconnect']), + revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), + connection: z.unknown().optional(), +}); +const tools: Tool[] = [ + { + name: PREFIX + 'open_connection', + description: + 'Open private GitLab connection setup. Choose local GitLab or an existing MCP server.', + inputSchema: EMPTY_INPUT, + _meta: { ui: { resourceUri: CONNECTION_PANEL_URI } }, + }, + { + name: PREFIX + 'connection', + description: 'Manage this plugin connection from the private setup panel.', + inputSchema: { + type: 'object', + properties: { + action: { enum: [...ConnectionAction.shape.action.options] }, + revision: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + connection: { type: 'object' }, + }, + required: ['action', 'revision'], + additionalProperties: false, + }, + outputSchema: OBJECT_OUTPUT, + _meta: { ui: { visibility: ['app'] } }, + }, + { + name: PREFIX + 'get_settings', + description: 'Read plugin connection and backend account settings.', + inputSchema: EMPTY_INPUT, + outputSchema: OBJECT_OUTPUT, + }, + { + name: PREFIX + 'update_settings', + description: 'Update backend account settings.', + inputSchema: { type: 'object', properties: { set: { type: 'object' } }, required: ['set'] }, + outputSchema: OBJECT_OUTPUT, + }, +]; + +const result = (value: Record): Result => ({ + content: [{ type: 'text', text: JSON.stringify(value) }], + structuredContent: value, +}); + +/** Public MCP bridge; GitLab behavior and account authority stay at the selected backend. */ +export function createPluginServer( + connections: Connections, +): Server { + const server = new Server( + { name: 'gitlab-mcp-plugin', version: '1.0.0' }, + { + capabilities: { + tools: { listChanged: true }, + resources: { listChanged: true, subscribe: true }, + extensions: MCP_APPS_EXTENSION, + prompts: { listChanged: true }, + logging: {}, + experimental: { + 'openai/settings': { + readTool: PREFIX + 'get_settings', + updateTool: PREFIX + 'update_settings', + }, + }, + }, + instructions: + 'Use gitlab_plugin_open_connection to configure or reconnect GitLab. Credentials belong only in the private connection panel, never in chat. GitLab tools appear after connecting.', + }, + ); + connections.changed = () => { + void server.notification({ method: 'notifications/tools/list_changed' }).catch(() => {}); + void server.notification({ method: 'notifications/resources/list_changed' }).catch(() => {}); + void server.notification({ method: 'notifications/prompts/list_changed' }).catch(() => {}); + }; + connections.wire = (backend) => { + backend.client.fallbackRequestHandler = (request, extra) => { + if (connections.active !== backend) + throw new McpError(ErrorCode.InvalidRequest, 'The connection is no longer active.'); + return server.request(request, ResultSchema, { signal: extra.signal }); + }; + backend.client.fallbackNotificationHandler = async (notification) => { + if (connections.active === backend) await server.notification(notification); + }; + }; + server.oninitialized = () => { + connections.capabilities = server.getClientCapabilities() ?? {}; + }; + server.setRequestHandler(SetLevelRequestSchema, async (request, extra) => { + const backend = await connections.ready(); + return backend?.client.getServerCapabilities()?.logging + ? forward(backend, request, extra, connections) + : {}; + }); + server.fallbackNotificationHandler = async (notification) => { + const backend = await connections.ready(); + if (backend) await backend.client.notification(notification); + }; + server.fallbackRequestHandler = async (request, extra) => { + const params = request.params ?? {}; + if ( + request.method === 'tools/call' && + typeof params.name === 'string' && + params.name.startsWith(PREFIX) + ) { + return localTool(params.name, params.arguments, connections, extra); + } + if (request.method === 'resources/read' && params.uri === CONNECTION_PANEL_URI) { + return { + contents: [ + { + uri: CONNECTION_PANEL_URI, + mimeType: 'text/html;profile=mcp-app', + text: connectionPanel(), + _meta: CONNECTION_PANEL_META, + }, + ], + }; + } + const backend = await connections.ready(); + if (request.method === 'tools/list') return toolListing(backend, request, extra, connections); + if (request.method === 'resources/list') + return resourceListing(backend, request, extra, connections); + if (request.method === 'prompts/list' && !backend?.client.getServerCapabilities()?.prompts) + return { prompts: [] }; + if (!backend) + throw new McpError( + ErrorCode.InvalidRequest, + 'Open GitLab connection settings to connect or reconnect.', + ); + return forward(backend, request, extra, connections); + }; + return server; +} + +async function toolListing( + backend: Backend | undefined, + request: Request, + extra: RequestHandlerExtra, + connections: Connections, +): Promise { + const response = backend ? await forward(backend, request, extra, connections) : { tools: [] }; + const offered = response.tools as Tool[]; + if (offered.some((tool) => tool.name.startsWith(PREFIX))) + throw new McpError(ErrorCode.InvalidRequest, 'Backend uses reserved plugin tool names.'); + return { + ...response, + tools: request.params?.cursor !== undefined ? offered : [...tools, ...offered], + }; +} + +async function resourceListing( + backend: Backend | undefined, + request: Request, + extra: RequestHandlerExtra, + connections: Connections, +): Promise { + const response = backend?.client.getServerCapabilities()?.resources + ? await forward(backend, request, extra, connections) + : { resources: [] }; + return { + ...response, + resources: + request.params?.cursor !== undefined + ? response.resources + : [ + { + uri: CONNECTION_PANEL_URI, + name: 'GitLab connection', + mimeType: 'text/html;profile=mcp-app', + _meta: CONNECTION_PANEL_META, + }, + ...(response.resources as unknown[]), + ], + }; +} + +async function forward( + backend: Backend, + request: Request, + extra: RequestHandlerExtra, + connections: Connections, +): Promise { + const token = request.params?._meta?.progressToken; + const response = await backend.client + .request(request, ResultSchema, { + signal: extra.signal, + ...(token !== undefined + ? { + onprogress: (progress) => { + void extra + .sendNotification({ + method: 'notifications/progress', + params: { ...progress, progressToken: token }, + }) + .catch(() => {}); + }, + } + : {}), + }) + .catch((error: unknown) => { + // JSON-RPC errors belong to the backend contract. Transport errors can quote HTTP bodies or credentials. + if (error instanceof McpError) throw error; + throw new McpError( + ErrorCode.InternalError, + 'The connection request failed. Its outcome may be unknown. Check connection settings before retrying; do not repeat writes automatically.', + ); + }); + if ( + connections.active !== backend || + connections.store.revision() !== backend.snapshot.revision + ) { + throw new McpError( + ErrorCode.InvalidRequest, + 'Connection changed while this request was running. Its outcome may be unknown. Do not repeat writes automatically.', + ); + } + return response; +} + +async function connectionAction(raw: unknown, connections: Connections): Promise { + const parsed = ConnectionAction.safeParse(raw); + if (!parsed.success) { + const revision = parsed.error.issues.some( + (issue) => issue.path.length === 0 || issue.path[0] === 'revision', + ); + throw new McpError( + ErrorCode.InvalidParams, + revision ? 'Invalid connection revision.' : 'Unknown connection action.', + ); + } + const args = parsed.data; + switch (args.action) { + case 'status': + await connections.ready(); + break; + case 'connect': + return result(await connections.connect(args.connection, args.revision)); + case 'reconnect': { + const saved = connections.store.read(); + if (!saved.connection) + throw new McpError(ErrorCode.InvalidParams, 'Configure a connection first.'); + const { authorization: _authorization, ...input } = saved.connection; + return result(await connections.connect(input, args.revision)); + } + case 'disconnect': + await connections.disconnect(args.revision); + break; + case 'cancel': + await connections.cancel(); + break; + } + return result(connections.status()); +} + +async function connectionSettings( + backend: Backend | undefined, + connections: Connections, + extra: RequestHandlerExtra, +): Promise { + let unavailable = false; + let page: Record = { + schema: { type: 'object', properties: {}, required: [] }, + values: {}, + layout: [], + }; + if (backend) { + try { + const response = await forward( + backend, + { method: 'tools/call', params: { name: 'get_settings', arguments: {} } }, + extra, + connections, + ); + if (response.isError) unavailable = true; + else if (response.structuredContent) + page = response.structuredContent as Record; + } catch { + // Local reconnection controls remain available when backend settings cannot be read. + unavailable = true; + } + } + return result({ + ...page, + layout: [ + { + kind: 'group', + title: 'Connection', + items: [ + { + kind: 'tool', + tool: PREFIX + 'open_connection', + description: unavailable + ? 'Backend account settings are unavailable. Set up, change or reconnect GitLab.' + : 'Set up, change or reconnect GitLab', + }, + ], + }, + ...(page.layout as unknown[]), + ], + }); +} + +async function localTool( + name: string, + raw: unknown, + connections: Connections, + extra: RequestHandlerExtra, +): Promise { + if (name === PREFIX + 'open_connection') + return { + content: [{ type: 'text', text: 'Configure your connection in the private panel.' }], + _meta: { ui: { resourceUri: CONNECTION_PANEL_URI } }, + }; + if (name === PREFIX + 'connection') return connectionAction(raw, connections); + const backend = await connections.ready(); + if (name === PREFIX + 'get_settings') return connectionSettings(backend, connections, extra); + if (name === PREFIX + 'update_settings' && backend) + return forward( + backend, + { + method: 'tools/call', + params: { name: 'update_settings', arguments: raw as Record }, + }, + extra, + connections, + ); + throw new McpError(ErrorCode.InvalidRequest, 'Connect GitLab before changing account settings.'); +} diff --git a/packages/gitlab-mcp/src/server.ts b/packages/gitlab-mcp/src/server.ts index 90fa9f47..f530e6a1 100644 --- a/packages/gitlab-mcp/src/server.ts +++ b/packages/gitlab-mcp/src/server.ts @@ -960,7 +960,7 @@ export async function startServer(): Promise { } // Graceful shutdown - close all sessions and save to storage backend before exit -async function gracefulShutdown(signal: string): Promise { +export async function gracefulShutdown(signal: string): Promise { logInfo('Shutting down GitLab MCP Server...', { signal }); try { await closeTagMatchers(); diff --git a/packages/gitlab-mcp/tests/plugin/bootstrap.test.cjs b/packages/gitlab-mcp/tests/plugin/bootstrap.test.cjs new file mode 100644 index 00000000..afcb6094 --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/bootstrap.test.cjs @@ -0,0 +1,94 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { spawn } = require('node:child_process'); +const { once } = require('node:events'); +const { runtimeRequire, runtimePath } = require('./runtime.cjs'); +const { Client } = runtimeRequire('@modelcontextprotocol/sdk/client/index.js'); +const { StdioClientTransport } = runtimeRequire('@modelcontextprotocol/sdk/client/stdio.js'); +const { ResultSchema } = runtimeRequire('@modelcontextprotocol/sdk/types.js'); + +for (const entry of ['main', 'core']) { + test( + `the compiled ${entry} entry serves MCP and closes cleanly`, + { timeout: 15000 }, + async () => { + // Run the repository entry as well as the separately qualified installed copy, retaining source coverage. + const { startWorkflowGitLab } = await import('../manual/fixtures/workflow-gitlab.mjs'); + const gitlab = await startWorkflowGitLab(); + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-entry-')); + const env = { GITLAB_API_URL: gitlab.url, GITLAB_TOKEN: 'fixture-token', LOG_JSON: 'true' }; + if (process.env.NODE_V8_COVERAGE) env.NODE_V8_COVERAGE = process.env.NODE_V8_COVERAGE; + const transport = new StdioClientTransport({ + command: process.execPath, + args: [ + '--enable-source-maps', + join(runtimePath, `${entry}.js`), + entry === 'main' ? dir : 'stdio', + ], + env, + stderr: 'pipe', + }); + let diagnostics = ''; + transport.stderr?.on('data', (chunk) => { + diagnostics += chunk; + }); + const client = new Client({ name: 'entry-contract', version: '1' }); + try { + await client.connect(transport); + const tools = await client.listTools(); + const name = entry === 'main' ? 'gitlab_plugin_get_settings' : 'get_profile'; + assert( + tools.tools.some((tool) => tool.name === name), + diagnostics, + ); + const response = await client.request( + { method: 'tools/call', params: { name, arguments: {} } }, + ResultSchema, + ); + assert.notEqual(response.isError, true, diagnostics); + assert(response.structuredContent); + if (entry === 'main') { + // Host termination must shut down the connector as reliably as closing stdin. + const closed = new Promise((resolve) => { + client.onclose = resolve; + }); + process.kill(transport.pid, 'SIGTERM'); + await closed; + assert.equal(transport.pid, null); + } + } finally { + await client.close(); + await gitlab.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, + ); +} + +test('the connector rejects an unexpanded host data placeholder', { timeout: 5000 }, async (t) => { + // A broken launcher must fail before creating a directory or exposing an unusable setup panel. + const child = spawn( + process.execPath, + [ + // Assert the application diagnostic independently of Node's SQLite experiment notice. + '--disable-warning=ExperimentalWarning', + '--enable-source-maps', + join(runtimePath, 'main.js'), + '${PLUGIN_DATA}', + ], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); + let diagnostics = ''; + child.stderr.on('data', (chunk) => { + diagnostics += chunk; + }); + const [code] = await once(child, 'exit', { signal: t.signal }); + assert.equal(code, 1); + assert.equal( + diagnostics, + 'GitLab plugin could not start. Check Node.js 24+ and the plugin data directory.\n', + ); +}); diff --git a/packages/gitlab-mcp/tests/plugin/connection.test.cjs b/packages/gitlab-mcp/tests/plugin/connection.test.cjs new file mode 100644 index 00000000..3d76f3c8 --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/connection.test.cjs @@ -0,0 +1,679 @@ +const { test } = require('node:test'); +const { AbortController, AbortSignal, DOMException } = globalThis; +const process = require('node:process'); +const assert = require('node:assert/strict'); +const { mkdtempSync, rmSync, mkdirSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { runtimeRequire } = require('./runtime.cjs'); +const { Client } = runtimeRequire('@modelcontextprotocol/sdk/client/index.js'); +const { InMemoryTransport } = runtimeRequire('@modelcontextprotocol/sdk/inMemory.js'); +const { StdioClientTransport } = runtimeRequire('@modelcontextprotocol/sdk/client/stdio.js'); +const { ResultSchema } = runtimeRequire('@modelcontextprotocol/sdk/types.js'); +const { Server } = runtimeRequire('@modelcontextprotocol/sdk/server/index.js'); +const { StreamableHTTPServerTransport } = runtimeRequire( + '@modelcontextprotocol/sdk/server/streamableHttp.js', +); +const { createServer } = require('node:http'); +const { DatabaseSync } = require('node:sqlite'); +const { ConnectionStore } = runtimeRequire('./connection-store.js'); +const { Connections } = runtimeRequire('./connections.js'); +const { createPluginServer } = runtimeRequire('./server.js'); + +test( + 'cancelling a replacement preserves the saved connection failure', + { timeout: 5000 }, + async () => { + // Cancelling proposed setup must not disguise an existing failed target as unconfigured. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-cancel-failure-')); + const store = new ConnectionStore(dir); + const target = { mode: 'remote', url: 'https://fixture.invalid/mcp', authentication: 'none' }; + const saved = store.commit(0, target); + const connections = new Connections(store); + connections.watcher.close(); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let pending; + try { + connections.make = async () => { + throw new Error('ECONNREFUSED'); + }; + await connections.ready(); + const failure = connections.status().message; + assert.equal(connections.status().state, 'failed'); + connections.make = async (snapshot) => ({ snapshot, client: { close: async () => {} } }); + connections.qualify = async () => { + entered.resolve(); + await release.promise; + }; + pending = connections.connect(target, saved.revision); + await Promise.race([ + entered.promise, + pending.then(() => assert.fail('Connection ended before reaching the test boundary.')), + ]); + assert.equal(connections.status().message, null); + await connections.cancel(); + assert.equal(connections.status().state, 'failed'); + assert.equal(connections.status().message, failure); + assert.equal(connections.status().url, target.url); + } finally { + release.resolve(); + try { + await pending; + } finally { + await connections.close(); + rmSync(dir, { recursive: true, force: true }); + } + } + }, +); + +test('one SDK deadline bounds paginated connection qualification', async (t) => { + // Individually successful pages must not let a broken endpoint keep setup running forever. + const { DEFAULT_REQUEST_TIMEOUT_MSEC } = runtimeRequire( + '@modelcontextprotocol/sdk/shared/protocol.js', + ); + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-deadline-')); + const connections = new Connections(new ConnectionStore(dir)); + const controller = new AbortController(); + let timeout; + t.mock.method(AbortSignal, 'timeout', (milliseconds) => { + timeout = milliseconds; + return controller.signal; + }); + let pages = 0; + const backend = { + client: { + connect: async (_transport, options) => { + assert.equal(options?.signal, controller.signal); + }, + listTools: async (_params, options) => { + assert.equal(options?.signal, controller.signal); + if (++pages === 2) controller.abort(new DOMException('Setup timed out.', 'TimeoutError')); + options.signal.throwIfAborted(); + return { tools: [], nextCursor: '' }; + }, + }, + }; + try { + await assert.rejects(connections.qualify(backend), { name: 'TimeoutError' }); + assert.equal(timeout, DEFAULT_REQUEST_TIMEOUT_MSEC); + assert.equal(pages, 2); + } finally { + await connections.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('the local core inherits routing and CA settings but not another server policy', async () => { + // The child must use the desktop network without inheriting unrelated GitLab restrictions. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-network-')); + const connections = new Connections(new ConnectionStore(dir)); + const network = [ + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'GITLAB_CA_CERT_PATH', + ]; + const names = [...network, 'GITLAB_ALLOWED_PROJECT_IDS']; + const before = names.map((name) => process.env[name]); + try { + for (const name of names) process.env[name] = `synthetic-${name}`; + const backend = await connections.make( + { + revision: 0, + connection: { + mode: 'local', + url: 'https://gitlab.example', + token: 'synthetic-token', + }, + }, + true, + ); + const inherited = backend.transport._serverParams.env; + for (const name of network) assert.equal(inherited[name], process.env[name]); + assert.equal(inherited.GITLAB_ALLOWED_PROJECT_IDS, undefined); + await backend.client.close(); + } finally { + names.forEach((name, index) => { + if (before[index] === undefined) delete process.env[name]; + else process.env[name] = before[index]; + }); + await connections.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test( + 'restoration cannot detach a replacement accepted while the old backend closes', + { timeout: 5000 }, + async () => { + // A restore and a manual connection may overlap during asynchronous child shutdown. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-restore-race-')); + const store = new ConnectionStore(dir); + const target = { mode: 'remote', url: 'https://fixture.invalid/mcp', authentication: 'none' }; + const before = store.commit(0, target); + const connections = new Connections(store); + connections.watcher.close(); + const closing = Promise.withResolvers(); + const release = Promise.withResolvers(); + const accepted = Promise.withResolvers(); + const replacements = []; + connections.active = { + snapshot: before, + client: { + close: async () => { + closing.resolve(); + await release.promise; + }, + }, + }; + const changed = store.commit(before.revision, target); + connections.make = async (snapshot) => { + const backend = { + snapshot, + closed: 0, + client: { + close: async () => { + backend.closed++; + }, + }, + }; + replacements.push(backend); + return backend; + }; + connections.qualify = async () => {}; + connections.changed = () => { + if (connections.active === replacements[0]) accepted.resolve(); + }; + try { + const restoring = connections.ready(); + await closing.promise; + const connecting = connections.connect(target, changed.revision); + await Promise.race([ + accepted.promise, + connecting.then(() => assert.fail('Connection ended before acceptance.')), + ]); + release.resolve(); + await Promise.all([restoring, connecting]); + assert.equal(connections.active, replacements[0]); + assert.equal(replacements.length, 1); + await connections.close(); + assert.equal(replacements[0].closed, 1); + } finally { + release.resolve(); + await connections.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, +); + +for (const stage of ['connect setup', 'restore setup', 'restore identity', 'connect identity']) { + test(`discarding ${stage} cannot activate a late backend`, { timeout: 5000 }, async () => { + // Hold the backend at an asynchronous boundary and cancel before its result can commit. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-cancel-')); + const store = new ConnectionStore(dir); + const target = { mode: 'remote', url: 'https://fixture.invalid/mcp', authentication: 'none' }; + const restoring = stage.startsWith('restore'); + const snapshot = restoring ? store.commit(0, target) : store.read(); + const connections = new Connections(store); + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let closed = 0; + const backend = { + snapshot, + client: { + close: async () => { + closed++; + }, + }, + }; + connections.make = async () => { + if (stage.endsWith('setup')) { + entered.resolve(); + await release.promise; + } + return backend; + }; + connections.qualify = async () => { + if (stage.endsWith('identity')) { + entered.resolve(); + await release.promise; + } + }; + try { + const opening = restoring + ? connections.ready() + : connections.connect(target, snapshot.revision); + await Promise.race([ + entered.promise, + opening.then(() => assert.fail('Connection ended before reaching the test boundary.')), + ]); + if (restoring) await connections.disconnect(snapshot.revision); + else await connections.cancel(); + release.resolve(); + await opening; + assert.equal(connections.active, undefined); + assert.equal(store.revision(), snapshot.revision + (restoring ? 1 : 0)); + assert(closed > 0); + } finally { + release.resolve(); + await connections.close(); + rmSync(dir, { recursive: true, force: true }); + } + }); +} + +test('a shared disconnect notifies the process losing its backend', async () => { + // A second process clears the shared selection; cached host catalogs must be invalidated. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-shared-')); + const store = new ConnectionStore(dir); + const other = new ConnectionStore(dir); + const connections = new Connections(store); + let closed = 0; + let notices = 0; + try { + const snapshot = store.commit(0, { + mode: 'remote', + url: 'https://fixture.invalid/mcp', + authentication: 'none', + }); + connections.active = { + snapshot, + client: { + close: async () => { + closed++; + }, + }, + }; + connections.changed = () => { + notices++; + }; + other.commit(snapshot.revision, null); + await connections.ready(); + assert.equal(closed, 1); + assert.equal(connections.active, undefined); + assert.equal(notices, 1); + } finally { + await connections.close(); + other.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test( + 'unconfigured setup, local GitLab read, failed replacement and restart', + { timeout: 60000 }, + async () => { + // Exercise the real core process and public MCP envelopes, without live credentials. + const { startWorkflowGitLab } = await import('../manual/fixtures/workflow-gitlab.mjs'); + const gitlab = await startWorkflowGitLab(); + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-contract-')); + const home = join(dir, 'home'); + mkdirSync(join(home, '.config/gitlab-mcp'), { recursive: true }); + writeFileSync( + join(home, '.config/gitlab-mcp/profiles.yaml'), + 'default_profile: other\nprofiles:\n other:\n host: unrelated.invalid\n api_url: http://127.0.0.1:1\n auth:\n type: oauth\n client_id_env: PATH\n', + ); + const previousHome = process.env.HOME; + const previousUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + const core = join(process.env.PLUGIN_CONTRACT_ROOT, 'runtime/src/plugin/core.js'); + let connections = new Connections(new ConnectionStore(dir), core); + let server = createPluginServer(connections); + const [front, back] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'plugin-contract', version: '1' }); + try { + await server.connect(back); + await client.connect(front); + // Hosts negotiate MCP Apps before rendering the private connection resource. + assert.deepEqual(client.getServerCapabilities().extensions?.['io.modelcontextprotocol/ui'], { + mimeTypes: ['text/html;profile=mcp-app'], + }); + const before = await client.listTools(); + assert(before.tools.some((t) => t.name === 'gitlab_plugin_open_connection')); + assert(!before.tools.some((t) => t.name === 'browse_merge_requests')); + const page = await client.readResource({ uri: 'ui://gitlab-mcp-local/connection.html' }); + assert.match(page.contents[0].text, /type="password"/); + const connected = await connections.connect( + { mode: 'local', url: gitlab.url, token: 'fixture-token', initTimeoutMs: 30000 }, + 0, + ); + assert.equal(connected.state, 'connected', JSON.stringify(connected)); + assert(!JSON.stringify(connected).includes('fixture-token')); + const listed = await client.listTools(); + assert(listed.tools.some((t) => t.name === 'browse_merge_requests')); + const read = await client.request( + { + method: 'tools/call', + params: { + name: 'browse_merge_requests', + arguments: { action: 'list', project_id: 'test/backend' }, + }, + }, + ResultSchema, + ); + assert.notEqual(read.isError, true, JSON.stringify(read)); + assert.match(JSON.stringify(read), /Fix parser bounds/); + const revision = connected.revision; + const failed = await connections.connect( + { mode: 'remote', url: 'http://127.0.0.1:1/mcp', authentication: 'none' }, + revision, + ); + assert.equal(failed.state, 'failed'); + assert.equal(connections.store.read().revision, revision); + assert.equal(connections.store.read().connection.mode, 'local'); + await client.close(); + await connections.close(); + connections = new Connections(new ConnectionStore(dir), core); + server = createPluginServer(connections); + assert(await connections.ready()); + assert.equal(connections.status().state, 'connected'); + await connections.disconnect(revision); + assert.equal(connections.status().state, 'unconfigured'); + assert.equal(connections.active, undefined); + } finally { + await client.close(); + await server.close(); + await connections.close(); + await gitlab.close(); + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + rmSync(dir, { recursive: true, force: true }); + } + }, +); + +test( + 'HTTP backend preserves schemas, results, errors and uncertain writes on switch', + { timeout: 30000 }, + async () => { + // A delayed write must never be replayed or attributed to a replacement backend. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-remote-contract-')); + let writes = 0, + release; + let started; + const began = new Promise((resolve) => { + started = resolve; + }); + const delayed = new Promise((resolve) => { + release = resolve; + }); + const payload = { + content: [{ type: 'text', text: '[{"title":"kept exactly"}]' }], + structuredContent: { items: [{ title: 'kept exactly' }] }, + _meta: { 'mcp/www_authenticate': ['Bearer error="insufficient_scope"'] }, + isError: true, + }; + let profile = { + content: [], + structuredContent: { id: 'remote-account', name: 'Remote fixture' }, + }; + let profileAvailable = true; + let authorization; + const http = createServer(async (req, res) => { + authorization = req.headers.authorization; + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + const backend = new Server( + { name: 'fixture', version: '1' }, + { capabilities: { tools: {} } }, + ); + backend.fallbackRequestHandler = async (request) => { + if (request.method === 'tools/list') { + // Qualification must follow even an empty opaque cursor to find the account tool. + if (request.params?.cursor === '') + return { + tools: profileAvailable + ? [ + { + name: 'get_profile', + description: 'Account identity', + inputSchema: { type: 'object' }, + }, + ] + : [], + }; + return { + tools: [ + { + name: 'write_once', + description: 'Delayed write', + inputSchema: { type: 'object' }, + annotations: { destructiveHint: true }, + }, + { + name: 'read_fixture', + description: 'Read fixture', + inputSchema: { type: 'object' }, + _meta: { custom: 'preserved' }, + }, + ], + nextCursor: '', + }; + } + if (request.params.name === 'get_profile') return profile; + if (request.params.name === 'write_once') { + writes++; + started(); + await delayed; + return { content: [{ type: 'text', text: 'written' }] }; + } + return payload; + }; + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on('close', () => { + void transport.close(); + void backend.close(); + }); + try { + let body = ''; + for await (const chunk of req) body += chunk; + await backend.connect(transport); + await transport.handleRequest(req, res, JSON.parse(body)); + } catch { + if (!res.headersSent) res.writeHead(500).end(); + } + }); + await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve)); + const connections = new Connections(new ConnectionStore(dir)); + const damaged = new DatabaseSync(join(dir, 'connection.sqlite')); + damaged.prepare('UPDATE connection SET value=? WHERE id=1').run('{"synthetic-stored-secret":'); + damaged.close(); + const server = createPluginServer(connections); + const [front, back] = InMemoryTransport.createLinkedPair(); + const client = new Client({ name: 'remote-contract', version: '1' }); + try { + await server.connect(back); + await client.connect(front); + // Corrupt saved data must preserve onboarding and allow an explicitly verified replacement. + assert.equal(connections.status().state, 'failed'); + assert(!JSON.stringify(connections.status()).includes('synthetic-stored-secret')); + assert( + (await client.listTools()).tools.some( + (tool) => tool.name === 'gitlab_plugin_open_connection', + ), + ); + let status = await connections.connect( + { + mode: 'remote', + url: `http://127.0.0.1:${http.address().port}/mcp`, + authentication: 'none', + }, + 0, + ); + assert.equal(status.state, 'connected', JSON.stringify(status)); + const list = await client.listTools(); + assert.deepEqual(list.tools.find((t) => t.name === 'read_fixture')._meta, { + custom: 'preserved', + }); + assert.equal(list.nextCursor, ''); + const nextPage = await client.listTools({ cursor: list.nextCursor }); + assert.deepEqual( + nextPage.tools.map((tool) => tool.name), + ['get_profile'], + ); + assert.equal(nextPage.nextCursor, undefined); + // Exhausting discovery without an identity tool must preserve the selected connection. + profileAvailable = false; + const unsupported = await connections.connect( + { + mode: 'remote', + url: `http://127.0.0.1:${http.address().port}/mcp`, + authentication: 'none', + }, + status.revision, + ); + assert.equal(unsupported.state, 'failed'); + assert.match(unsupported.message, /tool discovery/); + assert.equal(connections.store.revision(), status.revision); + assert.equal(connections.active.identity.id, 'remote-account'); + profileAvailable = true; + // A bearer credential must reach the backend without becoming public connection status. + status = await connections.connect( + { + mode: 'remote', + url: `http://127.0.0.1:${http.address().port}/mcp`, + authentication: 'bearer', + token: 'synthetic-bearer-secret', + }, + status.revision, + ); + assert.equal(status.state, 'connected', JSON.stringify(status)); + assert.equal(authorization, 'Bearer synthetic-bearer-secret'); + assert(!JSON.stringify(status).includes('synthetic-bearer-secret')); + await assert.rejects( + connections.connect( + { mode: 'remote', url: 'http://127.0.0.1:1/mcp', authentication: 'bearer' }, + status.revision, + ), + /Enter the bearer token/, + ); + await assert.rejects( + connections.connect( + { mode: 'remote', url: 'http://127.0.0.1:1/mcp', authentication: 'none' }, + status.revision - 1, + ), + /The connection changed/, + ); + assert.deepEqual( + await client.request( + { method: 'tools/call', params: { name: 'read_fixture', arguments: {} } }, + ResultSchema, + ), + payload, + ); + // A replacement must verify the account, preserve the selected backend on failure and sanitize diagnostics. + const acceptedProfile = profile; + for (const [error, reason] of [ + [ + { content: [], structuredContent: { id: '' } }, + 'Check the address, credentials and server availability.', + ], + [ + { content: [{ type: 'text', text: 'CONNECTION_FAILED' }], isError: true }, + 'The server is unreachable or did not answer. Check the address and network.', + ], + [ + { + content: [], + isError: true, + _meta: { 'mcp/www_authenticate': ['Bearer error="invalid_token"'] }, + }, + 'Authentication was rejected. Check the token or reconnect to sign in.', + ], + [ + { + content: [], + isError: true, + _meta: { 'mcp/www_authenticate': ['Bearer error="insufficient_scope"'] }, + }, + 'The account lacks permission. Check its GitLab token scopes.', + ], + [ + { content: [{ type: 'text', text: 'synthetic-provider-secret' }], isError: true }, + 'Check the address, credentials and server availability.', + ], + ]) { + profile = error; + const rejected = await connections.connect( + { + mode: 'remote', + url: `http://127.0.0.1:${http.address().port}/mcp`, + authentication: 'none', + }, + status.revision, + ); + assert.equal(rejected.state, 'failed'); + assert.equal( + rejected.message, + `Connection failed during account verification. ${reason} The previous connection is unchanged.`, + ); + assert.equal(connections.store.revision(), status.revision); + assert.equal(connections.active.identity.id, 'remote-account'); + } + profile = acceptedProfile; + const writing = client.request( + { method: 'tools/call', params: { name: 'write_once', arguments: {} } }, + ResultSchema, + ); + const rejected = assert.rejects(writing, /Connection (changed|closed)/i); + await began; + await connections.disconnect(status.revision); + release(); + await rejected; + assert.equal(writes, 1); + assert(!(await client.listTools()).tools.some((t) => t.name === 'write_once')); + } finally { + release(); + await client.close(); + await server.close(); + await connections.close(); + http.closeAllConnections(); + await new Promise((resolve) => http.close(resolve)); + rmSync(dir, { recursive: true, force: true }); + } + }, +); + +test( + 'packaged plugin starts without endpoint or external dependencies', + { timeout: 30000 }, + async () => { + // Moving the output away from the checkout verifies its installed dependency closure. + const root = process.env.PLUGIN_CONTRACT_ROOT; + if (!root) throw new Error('Set PLUGIN_CONTRACT_ROOT to the assembled plugin directory.'); + const dir = mkdtempSync(join(tmpdir(), 'gitlab-installed-plugin-')); + const client = new Client({ name: 'installed-plugin-contract', version: '1' }); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [join(root, 'runtime/src/plugin/main.js'), dir], + cwd: root, + stderr: 'pipe', + }); + let diagnostics = ''; + transport.stderr?.on('data', (chunk) => { + diagnostics += chunk; + }); + try { + await client.connect(transport); + const list = await client.listTools(); + assert.equal(list.tools.length, 4, diagnostics); + assert(!diagnostics.includes('Cannot find module')); + } finally { + await client.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/gitlab-mcp/tests/plugin/fixtures/hold-refresh-lock.cjs b/packages/gitlab-mcp/tests/plugin/fixtures/hold-refresh-lock.cjs new file mode 100644 index 00000000..8f1a5ab2 --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/fixtures/hold-refresh-lock.cjs @@ -0,0 +1,7 @@ +const { RefreshGuard } = require(process.argv[2]); +const lock = new RefreshGuard(process.argv[3]); +process.on('disconnect', () => process.exit(0)); +void lock.run(async () => { + process.send('locked'); + await new Promise(() => {}); +}); diff --git a/packages/gitlab-mcp/tests/plugin/lifecycle.test.cjs b/packages/gitlab-mcp/tests/plugin/lifecycle.test.cjs new file mode 100644 index 00000000..f09856df --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/lifecycle.test.cjs @@ -0,0 +1,133 @@ +const { test } = require('node:test'); +const process = require('node:process'); +const { setTimeout, clearTimeout } = require('node:timers'); +const assert = require('node:assert/strict'); +const { spawn } = require('node:child_process'); +const { createInterface } = require('node:readline'); +const { once } = require('node:events'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { createServer } = require('node:http'); +const { runtimeRequire, runtimePath } = require('./runtime.cjs'); +const { ConnectionStore } = runtimeRequire('./connection-store.js'); + +test('closing the host input terminates the installed connector', { timeout: 8000 }, async () => { + // SDK stdio transport has no EOF handler; a live watcher must not keep an uninstalled plugin running. + const data = mkdtempSync(join(tmpdir(), 'gitlab-plugin-eof-')); + const child = spawn( + process.execPath, + [join(process.env.PLUGIN_CONTRACT_ROOT, 'runtime/src/plugin/main.js'), data], + { stdio: ['pipe', 'pipe', 'ignore'] }, + ); + const lines = createInterface({ input: child.stdout }); + const initialized = once(lines, 'line'); + const exited = once(child, 'exit'); + let timer; + try { + child.stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'lifecycle-check', version: '1' }, + }, + }) + '\n', + ); + const [response] = await initialized; + assert(JSON.parse(response).result); + child.stdin.end(); + const [code] = await Promise.race([ + exited, + new Promise((_, reject) => { + timer = setTimeout(() => reject(Error('Connector survived host EOF.')), 3000); + }), + ]); + assert.equal(code, 0); + } finally { + clearTimeout(timer); + lines.close(); + if (child.exitCode === null) { + child.kill('SIGKILL'); + await exited; + } + rmSync(data, { recursive: true, force: true }); + } +}); + +test('host EOF cancels a selected backend still initializing', { timeout: 8000 }, async () => { + // Closing Codex must cancel an in-flight restore rather than wait for a remote initialization timeout. + const data = mkdtempSync(join(tmpdir(), 'gitlab-plugin-opening-')); + let began; + const initializing = new Promise((resolve) => { + began = resolve; + }); + const backend = createServer(async (request, response) => { + if (request.method !== 'POST') return response.writeHead(405).end(); + let body = ''; + for await (const chunk of request) body += chunk; + if (JSON.parse(body).method === 'initialize') began(); + }); + backend.listen(0, '127.0.0.1'); + await once(backend, 'listening'); + const store = new ConnectionStore(data); + store.commit(0, { + mode: 'remote', + url: `http://127.0.0.1:${backend.address().port}/mcp`, + authentication: 'none', + }); + store.close(); + const child = spawn( + process.execPath, + ['--enable-source-maps', join(runtimePath, 'main.js'), data], + { stdio: ['pipe', 'pipe', 'ignore'] }, + ); + const lines = createInterface({ input: child.stdout }); + const initialized = once(lines, 'line'); + const exited = once(child, 'exit'); + let timer; + try { + child.stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-11-25', + capabilities: {}, + clientInfo: { name: 'opening-contract', version: '1' }, + }, + }) + '\n', + ); + assert(JSON.parse((await initialized)[0]).result); + child.stdin.write( + JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n', + ); + child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list' }) + '\n'); + await initializing; + child.stdin.end(); + const [code] = await Promise.race([ + exited, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Connector waited for its unfinished backend.')), + 1500, + ); + }), + ]); + assert.equal(code, 0); + } finally { + clearTimeout(timer); + lines.close(); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + await exited; + } + backend.closeAllConnections(); + await new Promise((resolve) => backend.close(resolve)); + rmSync(data, { recursive: true, force: true }); + } +}); diff --git a/packages/gitlab-mcp/tests/plugin/oauth-binding.test.cjs b/packages/gitlab-mcp/tests/plugin/oauth-binding.test.cjs new file mode 100644 index 00000000..0439a7cf --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/oauth-binding.test.cjs @@ -0,0 +1,153 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createServer } = require('node:http'); +const { once } = require('node:events'); +const { URLSearchParams } = require('node:url'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { runtimeRequire } = require('./runtime.cjs'); +const { ConnectionStore } = runtimeRequire('./connection-store.js'); +const { ConnectionAuthorization } = runtimeRequire('./authorization.js'); + +test('expired browser sign-in closes its callback and reports failure once', async (t) => { + // An abandoned browser flow must release the listener without committing credentials. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-oauth-expiry-')); + const provider = new ConnectionAuthorization( + { redirectUrl: 'http://127.0.0.1/callback' }, + () => assert.fail('Expired sign-in must not save credentials.'), + { current: () => undefined, lockPath: join(dir, 'lock.sqlite') }, + ); + let failures = 0; + provider.onFailure = () => { + failures++; + }; + t.mock.timers.enable({ apis: ['setTimeout'] }); + try { + await provider.listen(); + const listener = provider.callback; + assert.equal(listener.listening, true); + t.mock.timers.tick(5 * 60_000); + assert.equal(listener.listening, false); + assert.equal(failures, 1); + t.mock.timers.tick(5 * 60_000); + assert.equal(failures, 1); + } finally { + await provider.dispose(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('staged OAuth invalidation clears only the requested SDK credentials', async () => { + // A registration or verifier failure must not erase unrelated staged credentials. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-staged-grant-')); + const initial = { + redirectUrl: 'http://127.0.0.1/callback', + client: { client_id: 'fixture-client' }, + tokens: { + access_token: 'fixture-access', + token_type: 'Bearer', + issuer: 'https://issuer.example', + }, + discovery: { authorizationServerUrl: 'https://issuer.example' }, + }; + try { + for (const scope of ['client', 'tokens', 'discovery', 'verifier', 'all']) { + let saved; + const provider = new ConnectionAuthorization( + { ...initial }, + (patch) => { + saved = patch; + }, + { + current: () => undefined, + lockPath: join(dir, 'lock.sqlite'), + }, + ); + try { + provider.saveCodeVerifier('fixture-verifier'); + provider.invalidateCredentials(scope); + const expected = {}; + for (const field of ['client', 'tokens', 'discovery']) { + const cleared = scope === 'all' || scope === field; + if (cleared) expected[field] = undefined; + assert.deepEqual(provider.data[field], cleared ? undefined : initial[field]); + } + assert.deepEqual(saved, expected); + if (scope === 'all' || scope === 'verifier') + assert.throws(() => provider.codeVerifier(), /Sign-in expired/); + else assert.equal(provider.codeVerifier(), 'fixture-verifier'); + } finally { + await provider.dispose(); + } + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test( + 'a refresh retains its grant issuer when shared discovery changes', + { timeout: 10000 }, + async () => { + // A remote discovery update must not relabel credentials issued by another authorization server. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-issuer-')); + const server = createServer((_request, response) => + response.writeHead(200, { 'Content-Type': 'application/json' }).end( + JSON.stringify({ + access_token: 'refreshed-access', + refresh_token: 'refreshed-grant', + token_type: 'Bearer', + }), + ), + ); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const issuer = `http://127.0.0.1:${server.address().port}`; + const store = new ConnectionStore(dir); + const selected = store.commit(0, { + mode: 'remote', + url: issuer + '/mcp', + authentication: 'oauth', + authorization: { + redirectUrl: 'http://127.0.0.1/callback', + tokens: { + access_token: 'expired-access', + refresh_token: 'original-grant', + token_type: 'Bearer', + issuer, + }, + discovery: { authorizationServerUrl: issuer }, + }, + }); + const provider = new ConnectionAuthorization( + selected.connection.authorization, + (patch) => store.updateAuthorization(selected.revision, patch), + { + current: () => store.readAuthorization(selected.revision), + lockPath: store.refreshLockPath, + }, + ); + try { + store.updateAuthorization(selected.revision, { + discovery: { authorizationServerUrl: 'https://changed-issuer.example' }, + }); + const response = await provider.fetch(issuer + '/token', { + method: 'POST', + body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: 'original-grant' }), + }); + assert.equal(response.status, 200); + assert.equal(store.readAuthorization(selected.revision).tokens.issuer, issuer); + assert.equal( + store.readAuthorization(selected.revision).tokens.access_token, + 'refreshed-access', + ); + } finally { + await provider.dispose(); + store.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/gitlab-mcp/tests/plugin/oauth-errors.test.cjs b/packages/gitlab-mcp/tests/plugin/oauth-errors.test.cjs new file mode 100644 index 00000000..eca6daf5 --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/oauth-errors.test.cjs @@ -0,0 +1,100 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { createServer } = require('node:http'); +const { once } = require('node:events'); +const { URL } = require('node:url'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { runtimeRequire } = require('./runtime.cjs'); +const { Connections } = runtimeRequire('./connections.js'); +const { ConnectionStore } = runtimeRequire('./connection-store.js'); + +for (const code of ['invalid_grant', 'invalid_client', 'vendor_failure']) { + test( + `OAuth ${code} preserves selection and suppresses provider diagnostics`, + { timeout: 10000 }, + async () => { + // Real SDK recovery must not clear a newer selection or expose a provider's arbitrary error text. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-oauth-error-')); + let origin; + const metadata = () => ({ + issuer: origin, + authorization_endpoint: origin + '/authorize', + token_endpoint: origin + '/token', + registration_endpoint: origin + '/register', + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + }); + const resource = () => ({ resource: origin + '/mcp', authorization_servers: [origin] }); + const server = createServer(async (request, response) => { + const json = (body, status = 200, headers = {}) => + response + .writeHead(status, { 'Content-Type': 'application/json', ...headers }) + .end(JSON.stringify(body)); + const url = new URL(request.url, origin); + if (url.pathname === '/token') + return json({ error: code, error_description: 'synthetic-provider-secret' }, 400); + if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) + return json(resource()); + if (url.pathname.startsWith('/.well-known/')) return json(metadata()); + if (url.pathname === '/register') { + let body = ''; + for await (const chunk of request) body += chunk; + return json({ ...JSON.parse(body), client_id: 'replacement-client' }, 201); + } + return json({ error: 'invalid_token' }, 401, { + 'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`, + }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + origin = `http://127.0.0.1:${server.address().port}`; + const connections = new Connections(new ConnectionStore(dir)); + try { + const selected = connections.store.commit(0, { + mode: 'remote', + url: origin + '/mcp', + authentication: 'oauth', + authorization: { + redirectUrl: 'http://127.0.0.1/callback', + client: { client_id: 'fixture-client', issuer: origin }, + tokens: { + access_token: 'expired-access', + refresh_token: 'fixture-refresh', + token_type: 'Bearer', + issuer: origin, + }, + discovery: { + authorizationServerUrl: origin, + authorizationServerMetadata: metadata(), + resourceMetadata: resource(), + }, + }, + }); + assert.equal(await connections.ready(), undefined); + assert.equal(connections.status().state, 'failed'); + assert(!JSON.stringify(connections.status()).includes('synthetic-provider-secret')); + const saved = connections.store.read(); + assert.equal(saved.revision, selected.revision); + assert.equal( + saved.connection.authorization.tokens === undefined, + code !== 'vendor_failure', + ); + // Clearing a failed selection in another chat also clears its stale failure status. + const other = new ConnectionStore(dir); + other.commit(saved.revision, null); + other.close(); + await connections.ready(); + assert.equal(connections.status().state, 'unconfigured'); + } finally { + await connections.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + rmSync(dir, { recursive: true, force: true }); + } + }, + ); +} diff --git a/packages/gitlab-mcp/tests/plugin/oauth.test.cjs b/packages/gitlab-mcp/tests/plugin/oauth.test.cjs new file mode 100644 index 00000000..6c0d98b6 --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/oauth.test.cjs @@ -0,0 +1,256 @@ +const { test } = require('node:test'); +const { URL, URLSearchParams } = require('node:url'); +const { fetch } = globalThis; +const assert = require('node:assert/strict'); +const { createServer } = require('node:http'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { once } = require('node:events'); +const { runtimeRequire } = require('./runtime.cjs'); +const { ConnectionStore } = runtimeRequire('./connection-store.js'); +const { Connections } = runtimeRequire('./connections.js'); + +test( + 'remote OAuth validates callbacks, commits only after sign-in, and persists for restart', + { timeout: 15000 }, + async (t) => { + // Exercise SDK discovery, registration and PKCE against a synthetic authorization server. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-oauth-')); + let origin; + let exchanges = 0; + let accessToken = 'fixture-access'; + let refreshToken = 'fixture-refresh'; + let blockedRefresh; + let refreshStarted; + let releaseRefresh; + let rejectInitialization = false; + const server = createServer(async (req, res) => { + const url = new URL(req.url, origin); + const json = (body, status = 200, headers = {}) => { + res + .writeHead(status, { 'Content-Type': 'application/json', ...headers }) + .end(JSON.stringify(body)); + }; + if (url.pathname.startsWith('/.well-known/oauth-protected-resource')) + return json({ + resource: origin + '/mcp', + authorization_servers: [origin], + scopes_supported: ['mcp:tools'], + }); + if (url.pathname.startsWith('/.well-known/')) + return json({ + issuer: origin, + authorization_endpoint: origin + '/authorize', + token_endpoint: origin + '/token', + registration_endpoint: origin + '/register', + response_types_supported: ['code'], + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + }); + let body = ''; + for await (const chunk of req) body += chunk; + if (url.pathname === '/register') + return json({ ...JSON.parse(body), client_id: 'fixture-client' }, 201); + if (url.pathname === '/token') { + const params = new URLSearchParams(body); + if (params.get('grant_type') === 'refresh_token') { + if (params.get('refresh_token') !== refreshToken) + return json({ error: 'invalid_grant' }, 400); + // A rotating provider spends the old token before its HTTP response arrives. + refreshToken = `fixture-refresh-${exchanges}`; + await new Promise((resolve) => setTimeout(resolve, 20)); + } else { + assert.equal(params.get('code'), 'fixture-code'); + assert(params.get('code_verifier')); + } + exchanges++; + if (blockedRefresh && params.get('grant_type') === 'refresh_token') { + refreshStarted(); + await blockedRefresh; + } + return json({ + access_token: accessToken, + refresh_token: refreshToken, + token_type: 'Bearer', + expires_in: 3600, + }); + } + if (url.pathname !== '/mcp') return json({}, 404); + if (req.headers.authorization !== `Bearer ${accessToken}`) + return json({ error: 'invalid_token' }, 401, { + 'WWW-Authenticate': `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp"`, + }); + if (req.method !== 'POST') { + res.writeHead(405).end(); + return; + } + const request = JSON.parse(body); + if (request.method === 'initialize' && rejectInitialization) { + rejectInitialization = false; + return json({ error: 'temporary' }, 503); + } + if (request.id === undefined) { + res.writeHead(202).end(); + return; + } + let result; + if (request.method === 'initialize') + result = { + protocolVersion: request.params.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: 'oauth-fixture', version: '1' }, + }; + else if (request.method === 'tools/list') + result = { tools: [{ name: 'get_profile', inputSchema: { type: 'object' } }] }; + else result = { content: [], structuredContent: { id: 'fixture-oauth-account' } }; + json({ jsonrpc: '2.0', id: request.id, result }); + }); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + origin = `http://127.0.0.1:${server.address().port}`; + let connections = new Connections(new ConnectionStore(dir)); + let other; + let recovering; + try { + const pending = await connections.connect( + { mode: 'remote', url: origin + '/mcp', authentication: 'oauth' }, + 0, + ); + assert.equal(pending.state, 'connecting', JSON.stringify(pending)); + assert.equal(connections.store.read().revision, 0); + const authorization = new URL(pending.authorizationUrl); + const callback = new URL(authorization.searchParams.get('redirect_uri')); + callback.searchParams.set('state', 'wrong-state'); + callback.searchParams.set('code', 'fixture-code'); + assert.equal((await fetch(callback)).status, 400); + assert.equal(exchanges, 0); + callback.searchParams.set('state', authorization.searchParams.get('state')); + // Correct state does not authorize a code issued by a different authorization server. + callback.searchParams.set('iss', 'https://another-issuer.example'); + assert.equal((await fetch(callback)).status, 400); + assert.equal(connections.store.revision(), 0); + assert.equal(connections.status().state, 'connecting'); + callback.searchParams.set('iss', origin); + const committed = new Promise((resolve, reject) => { + connections.changed = resolve; + t.signal.addEventListener( + 'abort', + () => reject(Error('OAuth callback did not finish before the test deadline.')), + { once: true }, + ); + }); + assert.equal((await fetch(callback)).status, 200); + await committed; + assert.equal(connections.status().state, 'connected', JSON.stringify(connections.status())); + assert.equal(exchanges, 1); + assert(!JSON.stringify(connections.status()).includes('fixture-access')); + await connections.close(); + connections = new Connections(new ConnectionStore(dir)); + assert(await connections.ready()); + assert.equal(exchanges, 1); + other = new Connections(new ConnectionStore(dir)); + assert(await other.ready()); + const sharedBackend = other.active; + const selection = connections.store.read().revision; + recovering = new Connections(new ConnectionStore(dir)); + rejectInitialization = true; + assert.equal(await recovering.ready(), undefined); + assert.equal(recovering.status().state, 'failed'); + + // A rejected access token refreshes through the SDK and is saved for this exact selection. + accessToken = 'fixture-rotated-access'; + await connections.active.client.listTools(); + assert.equal(exchanges, 2); + // Refreshing credentials must not select a new account or dispose another chat's transport. + assert.equal(connections.store.read().revision, selection); + await other.ready(); + assert.equal(other.active, sharedBackend); + assert.equal(other.active.authorization.tokens().access_token, accessToken); + // A failed restore retries when shared credentials change without selecting another account. + assert(await recovering.ready()); + await recovering.close(); + recovering = undefined; + assert.equal( + connections.store.read().connection.authorization.tokens.access_token, + accessToken, + ); + // Concurrent chats must share one rotation rather than replay a spent refresh token. + accessToken = 'fixture-concurrent-access'; + await Promise.all([connections.active.client.listTools(), other.active.client.listTools()]); + assert.equal(exchanges, 3); + + // Cancelling a new sign-in leaves the previous account usable and invalidates its callback. + const saved = connections.store.read(); + const cancelled = await connections.connect( + { mode: 'remote', url: origin + '/mcp', authentication: 'oauth' }, + saved.revision, + ); + const cancelledUrl = new URL(cancelled.authorizationUrl); + const staleCallback = new URL(cancelledUrl.searchParams.get('redirect_uri')); + staleCallback.searchParams.set('state', cancelledUrl.searchParams.get('state')); + staleCallback.searchParams.set('code', 'fixture-code'); + await connections.cancel(); + await assert.rejects(fetch(staleCallback)); + assert.equal(connections.store.read().revision, saved.revision); + assert(await connections.ready()); + + // Provider descriptions cannot escape into diagnostics, and denial does not replace the account. + const denied = await connections.connect( + { mode: 'remote', url: origin + '/mcp', authentication: 'oauth' }, + saved.revision, + ); + const deniedUrl = new URL(denied.authorizationUrl); + const denialCallback = new URL(deniedUrl.searchParams.get('redirect_uri')); + denialCallback.searchParams.set('state', deniedUrl.searchParams.get('state')); + denialCallback.searchParams.set('error', 'access_denied'); + denialCallback.searchParams.set('error_description', 'synthetic-provider-secret'); + const failed = new Promise((resolve) => { + connections.changed = resolve; + }); + await fetch(denialCallback); + await failed; + assert.equal(connections.status().state, 'failed'); + assert(!JSON.stringify(connections.status()).includes('synthetic-provider-secret')); + assert.equal(connections.store.read().revision, saved.revision); + + // An external selection wins even if an older authorization callback resumes afterward. + await connections.connect( + { mode: 'remote', url: origin + '/mcp', authentication: 'oauth' }, + saved.revision, + ); + const late = connections.pending.authorization.onCode; + // Delay change notifications to prove the storage fence rejects a late token response itself. + await other.close(); + other = undefined; + connections.watcher.close(); + blockedRefresh = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const started = new Promise((resolve) => { + refreshStarted = resolve; + }); + accessToken = 'fixture-late-refresh'; + const rejected = assert.rejects(connections.active.client.listTools()); + await started; + const replacement = new ConnectionStore(dir); + replacement.commit(saved.revision, null); + replacement.close(); + releaseRefresh(); + await rejected; + await connections.cancel(); + await late('fixture-code'); + assert.equal(connections.store.read().connection, null); + assert.equal(exchanges, 4); + } finally { + releaseRefresh?.(); + await recovering?.close(); + await other?.close(); + await connections.close(); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + rmSync(dir, { recursive: true, force: true }); + } + }, +); diff --git a/packages/gitlab-mcp/tests/plugin/refresh-lock.test.cjs b/packages/gitlab-mcp/tests/plugin/refresh-lock.test.cjs new file mode 100644 index 00000000..06436a2f --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/refresh-lock.test.cjs @@ -0,0 +1,93 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { fork } = require('node:child_process'); +const { once } = require('node:events'); +const { setImmediate } = require('node:timers/promises'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { runtimeRequire } = require('./runtime.cjs'); +const { RefreshGuard } = runtimeRequire('./refresh-guard.js'); + +test( + 'closing a refresh guard rejects waiting and future operations', + { timeout: 5000 }, + async () => { + // Shutdown must not run a queued token refresh after its authorization owner closes. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-refresh-close-')); + const path = join(dir, 'lock.sqlite'); + const holder = new RefreshGuard(path); + const waiter = new RefreshGuard(path); + let release; + let locked; + const started = new Promise((resolve) => { + locked = resolve; + }); + const pending = new Promise((resolve) => { + release = resolve; + }); + const holding = holder.run(async () => { + locked(); + await pending; + }); + let executed = false; + try { + await started; + const waiting = waiter.run(async () => { + executed = true; + }); + const rejected = assert.rejects(waiting, /OAuth connection closed/); + await setImmediate(); + await waiter.close(); + await rejected; + await assert.rejects( + waiter.run(async () => { + executed = true; + }), + /OAuth connection closed/, + ); + assert.equal(executed, false); + } finally { + release(); + await holding; + await waiter.close(); + await holder.close(); + rmSync(dir, { recursive: true, force: true }); + } + }, +); + +test('a crashed process releases the shared OAuth refresh lock', { timeout: 15000 }, async () => { + // The OS releases ownership after a crash; recovery must not depend on an expiring lease. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-refresh-')); + const path = join(dir, 'lock.sqlite'); + const child = fork( + join(__dirname, 'fixtures/hold-refresh-lock.cjs'), + [runtimeRequire.resolve('./refresh-guard.js'), path], + { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] }, + ); + const lock = new RefreshGuard(path); + try { + const [message] = await once(child, 'message'); + assert.equal(message, 'locked'); + let entered = false; + const acquiring = lock.run(async () => { + entered = true; + }); + await new Promise((resolve) => setTimeout(resolve, 250)); + assert.equal(entered, false); + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + await acquiring; + assert.equal(entered, true); + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + } + await lock.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/gitlab-mcp/tests/plugin/runtime.cjs b/packages/gitlab-mcp/tests/plugin/runtime.cjs new file mode 100644 index 00000000..11b42091 --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/runtime.cjs @@ -0,0 +1,6 @@ +const { createRequire } = require('node:module'); +const { join } = require('node:path'); + +const runtimePath = process.env.PLUGIN_RUNTIME_ROOT; +if (!runtimePath) throw new Error('Run plugin contracts through qualify-codex-plugin.mjs.'); +module.exports = { runtimePath, runtimeRequire: createRequire(join(runtimePath, 'main.js')) }; diff --git a/packages/gitlab-mcp/tests/plugin/server.test.cjs b/packages/gitlab-mcp/tests/plugin/server.test.cjs new file mode 100644 index 00000000..aad65727 --- /dev/null +++ b/packages/gitlab-mcp/tests/plugin/server.test.cjs @@ -0,0 +1,260 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mkdtempSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { runtimeRequire } = require('./runtime.cjs'); +const { Client } = runtimeRequire('@modelcontextprotocol/sdk/client/index.js'); +const { Server } = runtimeRequire('@modelcontextprotocol/sdk/server/index.js'); +const { InMemoryTransport } = runtimeRequire('@modelcontextprotocol/sdk/inMemory.js'); +const { ResultSchema, McpError, ErrorCode } = runtimeRequire('@modelcontextprotocol/sdk/types.js'); +const { Connections } = runtimeRequire('./connections.js'); +const { ConnectionStore } = runtimeRequire('./connection-store.js'); +const { createPluginServer } = runtimeRequire('./server.js'); + +test('the bridge preserves pagination, settings, resources, prompts and protocol errors', async () => { + // Exercise the public protocol on both sides, including an empty opaque cursor and private setup actions. + const dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-bridge-')); + const connections = new Connections(new ConnectionStore(dir)); + const plugin = createPluginServer(connections); + const frontend = new Client( + { name: 'bridge-contract', version: '1' }, + { capabilities: { sampling: {} } }, + ); + const [front, pluginTransport] = InMemoryTransport.createLinkedPair(); + const backend = new Server( + { name: 'backend-fixture', version: '1' }, + { + capabilities: { tools: {}, resources: {}, prompts: {}, logging: {} }, + }, + ); + const backendClient = new Client( + { name: 'backend-client', version: '1' }, + { capabilities: { sampling: {} } }, + ); + const [bridgeTransport, backendTransport] = InMemoryTransport.createLinkedPair(); + let reserved = false; + let settingsError = false; + const settings = { + schema: { type: 'object', properties: {} }, + values: { theme: 'dark' }, + layout: [], + }; + const failure = { content: [{ type: 'text', text: 'settings unavailable' }], isError: true }; + backend.fallbackRequestHandler = async (request, extra) => { + if (request.method === 'tools/list') + return { + tools: [ + { + name: reserved ? 'gitlab_plugin_reserved' : 'get_profile', + inputSchema: { type: 'object' }, + }, + ], + nextCursor: 'next-tools', + _meta: { fixture: 'tools' }, + }; + if (request.method === 'resources/list') + return { + resources: [{ uri: 'fixture://document', name: 'Fixture' }], + nextCursor: 'next-resources', + }; + if (request.method === 'resources/read') + return { + contents: [{ uri: request.params.uri, text: 'fixture content', mimeType: 'text/plain' }], + }; + if (request.method === 'prompts/list') return { prompts: [{ name: 'fixture-prompt' }] }; + if (request.method === 'prompts/get') + return { messages: [{ role: 'user', content: { type: 'text', text: 'fixture prompt' } }] }; + if (request.params.name === 'get_settings') { + if (settingsError === 'rpc') throw new McpError(ErrorCode.InternalError, 'Fixture outage.'); + return settingsError ? failure : { content: [], structuredContent: settings }; + } + if (request.params.name === 'update_settings') + return { content: [], structuredContent: request.params.arguments }; + if (request.params.name === 'rpc_error') + throw new McpError(ErrorCode.InvalidParams, 'Fixture validation.', { fixture: true }); + if (request.params.name === 'progress') + await extra.sendNotification({ + method: 'notifications/progress', + params: { progressToken: request.params._meta.progressToken, progress: 1, total: 1 }, + }); + return { content: [] }; + }; + const call = (name, args = {}) => + frontend.request({ method: 'tools/call', params: { name, arguments: args } }, ResultSchema); + try { + await plugin.connect(pluginTransport); + await frontend.connect(front); + assert.equal( + (await call('gitlab_plugin_open_connection'))._meta.ui.resourceUri, + 'ui://gitlab-mcp-local/connection.html', + ); + assert.equal( + (await call('gitlab_plugin_get_settings')).structuredContent.layout[0].title, + 'Connection', + ); + assert.equal((await frontend.listResources()).resources.length, 1); + assert.deepEqual((await frontend.listPrompts()).prompts, []); + await frontend.request({ method: 'logging/setLevel', params: { level: 'info' } }, ResultSchema); + await assert.rejects(call('gitlab_plugin_update_settings', { set: {} }), /Connect GitLab/); + await assert.rejects( + call('gitlab_plugin_connection', { action: 'reconnect', revision: 0 }), + /Configure a connection/, + ); + await assert.rejects( + call('gitlab_plugin_connection', { action: 'status', revision: -1 }), + /Invalid connection revision/, + ); + await assert.rejects( + call('gitlab_plugin_connection', { action: 'unknown', revision: 0 }), + /Unknown connection action/, + ); + await assert.rejects(call('unknown_backend_tool'), /Open GitLab connection settings/); + const snapshot = connections.store.commit(0, { + mode: 'remote', + url: 'http://127.0.0.1:1/mcp', + authentication: 'none', + }); + const selected = { client: backendClient, transport: bridgeTransport, snapshot }; + connections.active = selected; + connections.wire(selected); + await backend.connect(backendTransport); + await backendClient.connect(bridgeTransport); + // Backward requests and notifications must cross the bridge only for the active backend. + const sampled = { + role: 'assistant', + content: { type: 'text', text: 'fixture' }, + model: 'fixture', + }; + frontend.fallbackRequestHandler = async () => sampled; + const sampling = { method: 'sampling/createMessage', params: { messages: [], maxTokens: 1 } }; + assert.deepEqual(await backend.request(sampling, ResultSchema), sampled); + const toHost = Promise.withResolvers(); + frontend.fallbackNotificationHandler = async (message) => toHost.resolve(message); + await backend.notification({ + method: 'notifications/fixture', + params: { direction: 'host' }, + }); + assert.equal((await toHost.promise).params.direction, 'host'); + const toBackend = Promise.withResolvers(); + backend.fallbackNotificationHandler = async (message) => toBackend.resolve(message); + await frontend.notification({ + method: 'notifications/fixture', + params: { direction: 'backend' }, + }); + assert.equal((await toBackend.promise).params.direction, 'backend'); + connections.active = undefined; + await assert.rejects(backend.request(sampling, ResultSchema), /no longer active/); + connections.active = selected; + // A transport failure must not disclose its body, and late results must not regain authority. + const request = backendClient.request; + try { + backendClient.request = async () => { + throw new Error('synthetic-transport-secret'); + }; + await assert.rejects( + call('fixture'), + (error) => + error.code === ErrorCode.InternalError && + error.message.includes('outcome may be unknown') && + !error.message.includes('synthetic-transport-secret'), + ); + backendClient.request = async () => { + connections.active = undefined; + return { content: [] }; + }; + await assert.rejects(call('fixture'), /Connection changed while this request was running/); + } finally { + backendClient.request = request; + connections.active = selected; + } + const first = await frontend.listTools(); + assert(first.tools.some((tool) => tool.name === 'gitlab_plugin_connection')); + assert.equal(first.nextCursor, 'next-tools'); + const page = await frontend.listTools({ cursor: '' }); + assert.deepEqual( + page.tools.map((tool) => tool.name), + ['get_profile'], + ); + assert.deepEqual(page._meta, { fixture: 'tools' }); + assert.equal((await frontend.listResources()).resources.length, 2); + assert.deepEqual( + (await frontend.listResources({ cursor: '' })).resources.map((resource) => resource.uri), + ['fixture://document'], + ); + assert.equal( + (await frontend.readResource({ uri: 'fixture://document' })).contents[0].text, + 'fixture content', + ); + assert.equal((await frontend.listPrompts()).prompts[0].name, 'fixture-prompt'); + assert.equal( + (await frontend.getPrompt({ name: 'fixture-prompt' })).messages[0].content.text, + 'fixture prompt', + ); + await frontend.request({ method: 'logging/setLevel', params: { level: 'info' } }, ResultSchema); + assert.deepEqual( + (await call('gitlab_plugin_get_settings')).structuredContent.values, + settings.values, + ); + const patch = { set: { theme: 'light' } }; + assert.deepEqual((await call('gitlab_plugin_update_settings', patch)).structuredContent, patch); + // Account-setting failures must not remove the local route to reconnect or change targets. + for (const failureMode of [true, 'rpc']) { + settingsError = failureMode; + const unavailable = await call('gitlab_plugin_get_settings'); + assert.notEqual(unavailable.isError, true); + assert.equal(unavailable.structuredContent?.layout[0].title, 'Connection'); + assert.match(unavailable.structuredContent.layout[0].items[0].description, /unavailable/); + assert.deepEqual(unavailable.structuredContent.values, {}); + } + settingsError = false; + await assert.rejects( + call('rpc_error'), + (error) => error.code === ErrorCode.InvalidParams && error.data.fixture, + ); + const progress = []; + await frontend.request( + { method: 'tools/call', params: { name: 'progress', arguments: {} } }, + ResultSchema, + { onprogress: (value) => progress.push(value) }, + ); + assert.equal(progress[0].progress, 1); + reserved = true; + await assert.rejects(frontend.listTools(), /reserved plugin tool names/); + reserved = false; + assert.equal( + (await call('gitlab_plugin_connection', { action: 'status', revision: snapshot.revision })) + .structuredContent.revision, + snapshot.revision, + ); + await call('gitlab_plugin_connection', { action: 'cancel', revision: snapshot.revision }); + assert.equal( + ( + await call('gitlab_plugin_connection', { + action: 'reconnect', + revision: snapshot.revision, + }) + ).structuredContent.state, + 'failed', + ); + assert.equal( + ( + await call('gitlab_plugin_connection', { + action: 'connect', + revision: snapshot.revision, + connection: { mode: 'remote', url: 'http://127.0.0.1:1/mcp', authentication: 'none' }, + }) + ).structuredContent.state, + 'failed', + ); + await call('gitlab_plugin_connection', { action: 'disconnect', revision: snapshot.revision }); + assert.equal(connections.store.read().connection, null); + } finally { + await frontend.close(); + await plugin.close(); + await connections.close(); + await backendClient.close(); + await backend.close(); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/gitlab-mcp/tests/scripts/codex-live.test.mjs b/packages/gitlab-mcp/tests/scripts/codex-live.test.mjs new file mode 100644 index 00000000..2cb7d97c --- /dev/null +++ b/packages/gitlab-mcp/tests/scripts/codex-live.test.mjs @@ -0,0 +1,73 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { runEvaluation } from '../../scripts/evaluation-process.mjs'; + +test('live qualification reports a signalled HTTP core without waiting for an old close event', async () => { + // Closing the MCP client can finish after the HTTP child, so cleanup must preserve that failure. + const directory = mkdtempSync(join(tmpdir(), 'gitlab-live-signal-')); + mkdirSync(join(directory, 'runtime')); + const evaluation = new URL('../../scripts/evaluation-process.mjs', import.meta.url).href; + const original = evaluation + '?unmocked'; + const live = new URL('../../scripts/codex-plugin-live.mjs', import.meta.url).href; + const hook = join(directory, 'hook.mjs'); + const client = ` + export class Client { + async connect() {} + async request() { return {structuredContent: {state: 'connected', revision: 1, id: 'fixture-user'}}; } + async close() { await globalThis.fixtureHttpClosed; } + } + `; + const launcher = ` + import * as original from ${JSON.stringify(original)}; + import { once } from 'node:events'; + export * from ${JSON.stringify(original)}; + export function launch(command, _args, options) { + const child = original.launch(command, ['-e', "process.kill(process.pid, 'SIGTERM')"], options); + globalThis.fixtureHttpClosed = once(child, 'close'); + return child; + } + `; + writeFileSync( + hook, + ` + import { registerHooks } from 'node:module'; + registerHooks({ load(url, context, next) { + if (url === ${JSON.stringify(import.meta.resolve('@modelcontextprotocol/sdk/client/index.js'))}) + return {format: 'module', shortCircuit: true, source: ${JSON.stringify(client)}}; + if (url === ${JSON.stringify(evaluation)}) + return {format: 'module', shortCircuit: true, source: ${JSON.stringify(launcher)}}; + return next(url, context); + }}); + `, + ); + const driver = ` + import { qualifyLiveConnections } from ${JSON.stringify(live)}; + try { await qualifyLiveConnections(${JSON.stringify(directory)}); } + catch (error) { console.error(error.message); process.exitCode = 1; } + `; + try { + const result = await runEvaluation( + process.execPath, + ['--import', hook, '--input-type=module', '-e', driver], + { + cwd: directory, + timeoutMs: 2000, + env: { + ...process.env, + TMPDIR: directory, + TMP: directory, + TEMP: directory, + GITLAB_API_URL: 'https://gitlab.example.com/api/v4', + GITLAB_TOKEN: 'fixture-token', + }, + }, + ); + assert.equal(result.code, 1); + assert.match(result.stderr, /Live HTTP core stopped before startup/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/gitlab-mcp/tests/scripts/plugin-artifact.test.mjs b/packages/gitlab-mcp/tests/scripts/plugin-artifact.test.mjs new file mode 100644 index 00000000..b233315b --- /dev/null +++ b/packages/gitlab-mcp/tests/scripts/plugin-artifact.test.mjs @@ -0,0 +1,144 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync, rmSync, realpathSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { runEvaluation } from '../../scripts/evaluation-process.mjs'; + +const require = createRequire(import.meta.url); +const { buildPlugin } = require('../../dist/src/cli/plugin/build.js'); +const { createZip } = require('../../dist/src/cli/plugin/zip.js'); + +test( + 'the release script exports an archive qualified with a caller-relative Codex executable', + { timeout: 30000 }, + async () => { + // The lifecycle child runs in the package directory, not beside an operator's relative CLI path. + const directory = mkdtempSync(join(tmpdir(), 'gitlab-codex-relative-')); + const name = process.platform === 'win32' ? 'fixture-codex.cmd' : 'fixture-codex'; + const executable = join(directory, 'bin', name); + mkdirSync(join(directory, 'bin')); + writeFileSync( + executable, + process.platform === 'win32' + ? '@echo off\r\necho fixture-codex\r\n' + : '#!/bin/sh\nprintf fixture-codex\n', + { mode: 0o755 }, + ); + const receipt = join(directory, 'paths.json'); + const evaluation = new URL('../../scripts/evaluation-process.mjs', import.meta.url).href; + const original = evaluation + '?unmocked'; + const runnerSource = ` + import * as original from ${JSON.stringify(original)}; + import { writeFileSync } from 'node:fs'; + export * from ${JSON.stringify(original)}; + export async function runEvaluation(command, args, options) { + if (args[0] === 'scripts/codex-plugin-lifecycle.mjs') { + writeFileSync(${JSON.stringify(receipt)}, JSON.stringify({lifecycle: args[2]})); + return original.runEvaluation(args[2], ['--version'], options); + } + return original.runEvaluation(command, args, options); + } + `; + const hook = join(directory, 'hook.mjs'); + writeFileSync( + hook, + ` + import { registerHooks } from 'node:module'; + registerHooks({ load(url, context, next) { + if (url === ${JSON.stringify(evaluation)}) + return {format: 'module', shortCircuit: true, source: ${JSON.stringify(runnerSource)}}; + return next(url, context); + }}); + `, + ); + try { + const build = fileURLToPath(new URL('../../scripts/build-codex-plugin.mjs', import.meta.url)); + const release = join(directory, 'release'); + const built = await runEvaluation(process.execPath, [build, release], { + cwd: directory, + env: process.env, + timeoutMs: 15000, + }); + assert.equal(built.code, 0, built.stdout + built.stderr); + const removedCommand = await runEvaluation( + process.execPath, + [join(release, 'plugins/gitlab-mcp/runtime/src/main.js'), 'plugin', 'build'], + { cwd: directory, env: process.env, timeoutMs: 5000 }, + ); + assert.equal(removedCommand.code, 1, removedCommand.stdout + removedCommand.stderr); + assert.match(removedCommand.stderr, /Unknown command: plugin/); + const script = fileURLToPath( + new URL('../../scripts/qualify-codex-plugin.mjs', import.meta.url), + ); + const result = await runEvaluation( + process.execPath, + [ + '--import', + hook, + script, + '--archive', + join(release, 'gitlab-mcp-codex.zip'), + '--codex', + './bin/' + name, + ], + { cwd: directory, env: process.env, timeoutMs: 25000 }, + ); + assert.equal(result.code, 0, result.stdout + result.stderr); + assert.deepEqual(JSON.parse(readFileSync(receipt, 'utf8')), { + lifecycle: realpathSync(executable), + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); + +test( + 'release qualification executes the supplied runtime rather than the checkout', + { timeout: 30000 }, + async () => { + // A validly checksummed ZIP with broken connection behavior must fail even when checkout code is healthy. + const directory = mkdtempSync(join(tmpdir(), 'gitlab-artifact-contract-')); + try { + const artifact = buildPlugin({ + flavor: 'private', + marketplaceZip: true, + outDir: directory, + packageRoot: process.cwd(), + }); + const connection = join(artifact.pluginDir, 'runtime/src/plugin/connections.js'); + writeFileSync( + connection, + readFileSync(connection, 'utf8') + + '\nexports.Connections.prototype.connect = async () => { throw new Error("Synthetic modified runtime."); };\n', + ); + const entries = artifact.files.map((path) => ({ + path: 'plugins/gitlab-mcp/' + path, + data: readFileSync(join(artifact.pluginDir, path)), + })); + entries.push({ + path: '.agents/plugins/marketplace.json', + data: readFileSync(artifact.marketplacePath), + }); + const zip = createZip(entries); + writeFileSync(artifact.zipPath, zip); + writeFileSync( + artifact.checksumPath, + `${createHash('sha256').update(zip).digest('hex')} gitlab-mcp-codex.zip\n`, + ); + const result = await runEvaluation( + process.execPath, + ['scripts/qualify-codex-plugin.mjs', '--archive', artifact.zipPath], + { cwd: process.cwd(), env: process.env, timeoutMs: 20000 }, + ); + assert.equal(result.code, 1, result.stdout + result.stderr); + assert.match(result.stdout + result.stderr, /Synthetic modified runtime/); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }, +); diff --git a/packages/gitlab-mcp/tests/unit/cli-utils.test.ts b/packages/gitlab-mcp/tests/unit/cli-utils.test.ts index 307729c2..fa5d1de1 100644 --- a/packages/gitlab-mcp/tests/unit/cli-utils.test.ts +++ b/packages/gitlab-mcp/tests/unit/cli-utils.test.ts @@ -59,24 +59,14 @@ describe('cli-utils', () => { installArgs: [], docker: false, dockerArgs: [], - plugin: false, - pluginArgs: [], }); }); - it('should route the plugin subcommand with its arguments', () => { - const result = parseCliArgs([ - 'node', - 'main.js', - 'plugin', - 'build', - '--endpoint', - 'https://mcp.example.com/mcp', - ]); - - expect(result.plugin).toBe(true); - expect(result.pluginArgs).toEqual(['build', '--endpoint', 'https://mcp.example.com/mcp']); - expect(result.install).toBe(false); + it('rejects the removed packaging command instead of starting a server', () => { + // Packaging belongs to release automation and must not be dispatched by the server CLI. + expect(() => parseCliArgs(['node', 'main.js', 'plugin', 'build'])).toThrow( + 'Unknown command: plugin', + ); }); it('should parse --profile flag', () => { @@ -151,8 +141,6 @@ describe('cli-utils', () => { installArgs: [], docker: false, dockerArgs: [], - plugin: false, - pluginArgs: [], }); }); diff --git a/packages/gitlab-mcp/tests/unit/cli/plugin/build.test.ts b/packages/gitlab-mcp/tests/unit/cli/plugin/build.test.ts index 8d618827..7d6e9d71 100644 --- a/packages/gitlab-mcp/tests/unit/cli/plugin/build.test.ts +++ b/packages/gitlab-mcp/tests/unit/cli/plugin/build.test.ts @@ -14,6 +14,8 @@ import { } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; +import { createHash } from 'crypto'; +import { inflateRawSync } from 'zlib'; import { buildPlugin, findPackageRoot, parseAuthor } from '../../../../src/cli/plugin/build'; import { PluginValidationError } from '../../../../src/cli/plugin/validate'; @@ -39,7 +41,107 @@ function readJson(path: string): Record { return JSON.parse(readFileSync(path, 'utf8')) as Record; } +function zipFiles(path: string): Map { + const zip = readFileSync(path); + const files = new Map(); + let offset = 0; + while (zip.readUInt32LE(offset) === 0x04034b50) { + const size = zip.readUInt32LE(offset + 18); + const nameLength = zip.readUInt16LE(offset + 26); + const extraLength = zip.readUInt16LE(offset + 28); + const name = zip.toString('utf8', offset + 30, offset + 30 + nameLength); + const dataAt = offset + 30 + nameLength + extraLength; + files.set(name, inflateRawSync(zip.subarray(dataAt, dataAt + size))); + offset = dataAt + size; + } + return files; +} + describe('buildPlugin', () => { + it('exports a complete standalone marketplace and a verifiable checksum', () => { + // Downloading the submission ZIP alone loses the catalog needed for installation. + const built = buildPlugin({ + flavor: 'private', + npmRuntime: true, + marketplaceZip: true, + outDir, + }); + const files = zipFiles(built.zipPath); + const catalog = JSON.parse(files.get('.agents/plugins/marketplace.json')!.toString()); + expect(catalog).toEqual(readJson(built.marketplacePath)); + expect([...files.keys()].sort()).toEqual( + [ + '.agents/plugins/marketplace.json', + ...built.files.map((path) => `plugins/gitlab-mcp/${path}`), + ].sort(), + ); + expect(files.get('plugins/gitlab-mcp/mcp.json')).toEqual( + readFileSync(join(built.pluginDir, 'mcp.json')), + ); + expect(built.zipPath).toBe(join(outDir, 'gitlab-mcp-codex.zip')); + const checksum = createHash('sha256').update(readFileSync(built.zipPath)).digest('hex'); + expect(readFileSync(built.checksumPath!, 'utf8')).toBe(`${checksum} gitlab-mcp-codex.zip\n`); + expect( + buildPlugin({ flavor: 'private', npmRuntime: true, marketplaceZip: true, outDir }).sha256, + ).toBe(checksum); + }); + + it('keeps another marketplace private when exporting its GitLab package', () => { + // A release archive must not disclose or reference a team's other local plugins. + const path = join(outDir, '.agents/plugins/marketplace.json'); + mkdirSync(join(outDir, '.agents/plugins'), { recursive: true }); + const existing = { name: 'private-team', plugins: [{ name: 'private-plugin' }] }; + writeFileSync(path, JSON.stringify(existing)); + const built = buildPlugin({ + flavor: 'private', + npmRuntime: true, + marketplaceZip: true, + outDir, + }); + const catalog = JSON.parse( + zipFiles(built.zipPath).get('.agents/plugins/marketplace.json')!.toString(), + ); + expect(catalog.name).toBe('gitlab-mcp'); + expect(catalog.plugins.map((plugin: { name: string }) => plugin.name)).toEqual(['gitlab-mcp']); + expect(readJson(path)).toMatchObject({ + name: 'private-team', + plugins: expect.arrayContaining(existing.plugins), + }); + }); + + it('rejects marketplace archives for public directory submission before writing', () => { + // Directory submission requires plugin.json at the ZIP root instead of a catalog. + expect(() => buildPlugin({ ...PUBLIC, marketplaceZip: true, outDir })).toThrow( + 'Marketplace archives cannot use directory submission rules.', + ); + expect(existsSync(join(outDir, 'plugins'))).toBe(false); + }); + + it('builds a version-pinned configurable npm launcher without an endpoint', () => { + // Repository marketplaces reference the shipped entry point, without vendoring dependencies. + const built = buildPlugin({ flavor: 'private', npmRuntime: true, outDir }); + expect(readJson(join(built.pluginDir, 'mcp.json'))).toEqual({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', + mcpServers: { + 'gitlab-sw': { + type: 'stdio', + command: 'npx', + cwd: '${PLUGIN_ROOT}', + args: [ + '-y', + '--package', + `@structured-world/gitlab-mcp@${built.version}`, + 'gitlab-mcp-plugin', + '${PLUGIN_DATA}', + ], + }, + }, + }); + expect(built.files.some((path) => path.startsWith('runtime/'))).toBe(false); + expect(() => buildPlugin({ ...PUBLIC, npmRuntime: true, outDir })).toThrow( + 'cannot be combined', + ); + }); it('writes the plugin folder, a local marketplace and a ZIP for the endpoint', () => { const result = buildPlugin({ ...PUBLIC, outDir }); const version = (readJson(join(ROOT, 'package.json')) as { version: string }).version; @@ -238,6 +340,25 @@ describe('buildPlugin from a minimal package', () => { rmSync(root, { recursive: true, force: true }); }); + it('builds a public desktop-only package with its local runtime and no HTTP endpoint', () => { + // Approved local MCP submissions use stdio, so public listing rules must not require HTTP. + writePackage({ ...readJson(join(ROOT, 'package.json')), dependencies: {} }); + mkdirSync(join(root, 'dist/src/plugin'), { recursive: true }); + writeFileSync(join(root, 'dist/src/plugin/main.js'), 'module.exports = {};'); + const built = buildPlugin({ ...PUBLIC, endpoint: undefined, packageRoot: root, outDir }); + expect(readJson(join(built.pluginDir, 'mcp.json')).mcpServers).toEqual({ + 'gitlab-sw': { + type: 'stdio', + command: 'node', + args: ['${PLUGIN_ROOT}/runtime/src/plugin/main.js', '${PLUGIN_DATA}'], + cwd: '${PLUGIN_ROOT}', + }, + }); + expect(zipFiles(built.zipPath).get('runtime/src/plugin/main.js')?.toString()).toBe( + 'module.exports = {};', + ); + }); + it('takes links from string fields and the npm homepage when there is no mcp block', () => { writePackage({ name: '@structured-world/gitlab-mcp', diff --git a/packages/gitlab-mcp/tests/unit/cli/plugin/command.test.ts b/packages/gitlab-mcp/tests/unit/cli/plugin/command.test.ts deleted file mode 100644 index bf0ab11e..00000000 --- a/packages/gitlab-mcp/tests/unit/cli/plugin/command.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Unit tests for `gitlab-mcp plugin build` argument handling and exit codes. - */ - -import { mkdtempSync, rmSync } from 'fs'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; -import { parsePluginArgs, runPluginCommand } from '../../../../src/cli/plugin/command'; - -describe('parsePluginArgs', () => { - it('defaults to a private package in ./gitlab-mcp-plugin', () => { - expect(parsePluginArgs(['build', '--endpoint', 'https://mcp.example.com/mcp'])).toEqual({ - flavor: 'private', - outDir: resolve('gitlab-mcp-plugin'), - endpoint: 'https://mcp.example.com/mcp', - }); - }); - - it('reads every option', () => { - expect( - parsePluginArgs([ - 'build', - '--public', - '--endpoint', - 'https://mcp.example.com/mcp', - '--out', - 'dist-plugin', - '--privacy-url', - 'https://example.com/privacy', - '--terms-url', - 'https://example.com/terms', - ]), - ).toEqual({ - flavor: 'public', - outDir: resolve('dist-plugin'), - endpoint: 'https://mcp.example.com/mcp', - privacyPolicyUrl: 'https://example.com/privacy', - termsOfServiceUrl: 'https://example.com/terms', - }); - }); - - it('requires the build action', () => { - expect(() => parsePluginArgs([])).toThrow('Usage: gitlab-mcp plugin build'); - expect(() => parsePluginArgs(['publish'])).toThrow('Usage: gitlab-mcp plugin build'); - }); - - it('requires an endpoint', () => { - expect(() => parsePluginArgs(['build', '--public'])).toThrow('--endpoint is required'); - }); - - it('refuses a flag without its value and an unknown flag', () => { - expect(() => parsePluginArgs(['build', '--endpoint'])).toThrow('--endpoint needs a value'); - expect(() => parsePluginArgs(['build', '--endpoint', '--public'])).toThrow( - '--endpoint needs a value', - ); - expect(() => parsePluginArgs(['build', '--endpoint', 'https://x.example', '--zip'])).toThrow( - 'Unknown option --zip', - ); - }); -}); - -describe('runPluginCommand', () => { - let outDir: string; - let lines: string[]; - let errors: string[]; - const out = (line: string) => lines.push(line); - const err = (line: string) => errors.push(line); - - beforeEach(() => { - outDir = mkdtempSync(join(tmpdir(), 'gitlab-mcp-plugin-cmd-')); - lines = []; - errors = []; - }); - - afterEach(() => { - rmSync(outDir, { recursive: true, force: true }); - }); - - it('exits 2 with usage on bad arguments', () => { - expect(runPluginCommand(['build'], out, err)).toBe(2); - expect(errors.join('\n')).toContain('--endpoint is required'); - expect(lines).toEqual([]); - }); - - it('exits 1 and lists every problem when validation fails', () => { - const code = runPluginCommand( - ['build', '--public', '--endpoint', 'https://mcp.example.com/mcp', '--out', outDir], - out, - err, - ); - expect(code).toBe(1); - expect(errors[0]).toContain('interface.privacyPolicyURL is required'); - expect(errors[0]).toContain('interface.termsOfServiceURL is required'); - }); - - it('exits 1 with the reason when the endpoint is refused', () => { - const code = runPluginCommand( - ['build', '--endpoint', 'http://mcp.example.com/mcp', '--out', outDir], - out, - err, - ); - expect(code).toBe(1); - expect(errors).toEqual([ - 'Error: Endpoint must use HTTPS (plain HTTP is accepted only on loopback)', - ]); - }); - - it('exits 0 and tells a private operator how to add the marketplace', () => { - const code = runPluginCommand( - ['build', '--endpoint', 'https://mcp.example.com/mcp', '--out', outDir], - out, - err, - ); - expect(code).toBe(0); - expect(errors).toEqual([]); - expect(lines[0]).toMatch( - /^Built gitlab-mcp plugin \d+\.\d+\.\d+ for https:\/\/mcp\.example\.com\/mcp$/, - ); - expect(lines.join('\n')).toContain(`codex plugin marketplace add "${outDir}"`); - // The plugin is listed under its display name, which the hint must match. - expect(lines.join('\n')).toContain( - 'then install GitLab by Structured World from the Plugins Directory.', - ); - }); - - it('tells a public publisher to upload the ZIP', () => { - const code = runPluginCommand( - [ - 'build', - '--public', - '--endpoint', - 'https://mcp.example.com/mcp', - '--privacy-url', - 'https://example.com/privacy', - '--terms-url', - 'https://example.com/terms', - '--out', - outDir, - ], - out, - err, - ); - expect(code).toBe(0); - expect(lines.join('\n')).toContain('Upload the ZIP in the OpenAI platform dashboard'); - }); -}); diff --git a/packages/gitlab-mcp/tests/unit/cli/plugin/connection-store.test.ts b/packages/gitlab-mcp/tests/unit/cli/plugin/connection-store.test.ts new file mode 100644 index 00000000..004c7cf0 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/cli/plugin/connection-store.test.ts @@ -0,0 +1,120 @@ +import { mkdtempSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ConnectionStore } from '../../../../src/plugin/connection-store'; +import { DatabaseSync } from 'node:sqlite'; + +describe('plugin connection storage', () => { + let dir: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'gitlab-plugin-store-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('rejects malformed persisted connections without exposing stored values', () => { + // Another version or a damaged record must not bypass validation or leak credentials. + const store = new ConnectionStore(dir); + const database = new DatabaseSync(join(dir, 'connection.sqlite')); + try { + for (const value of [ + JSON.stringify({ mode: 'local', url: 'not a URL', token: 'synthetic-secret' }), + JSON.stringify({ mode: 'local', url: 'https://gitlab.example' }), + JSON.stringify({ mode: 'remote', url: 'https://mcp.example/mcp', authentication: 'oauth' }), + '{"synthetic-stored-secret":', + ]) { + database.prepare('UPDATE connection SET value=? WHERE id=1').run(value); + expect(() => store.read()).toThrow('saved connection'); + } + } finally { + database.close(); + store.close(); + } + }); + + it('rejects late setup and credential writes across processes', () => { + // Two chats share one selected connection; an older callback cannot replace it. + const first = new ConnectionStore(dir); + const second = new ConnectionStore(dir); + try { + const before = first.read(); + const active = second.commit(before.revision, { + mode: 'local', + url: 'https://gitlab.example', + token: 'synthetic-secret', + }); + expect(active).toBeDefined(); + expect(() => + first.commit(before.revision, { + mode: 'remote', + url: 'https://mcp.example/mcp', + authentication: 'none', + }), + ).toThrow('changed'); + second.commit(active.revision, null); + expect(() => first.commit(active.revision, active.connection)).toThrow('changed'); + expect(first.read().connection).toBeNull(); + } finally { + first.close(); + second.close(); + } + }); + + it('persists privately without retaining disconnected credentials', () => { + // Restart reads the selected target; disconnect removes its saved secret. + let store = new ConnectionStore(dir); + store.commit(0, { mode: 'local', url: 'https://gitlab.example', token: 'synthetic-secret' }); + store.close(); + store = new ConnectionStore(dir); + try { + expect(store.read().connection).toMatchObject({ mode: 'local', token: 'synthetic-secret' }); + // Windows inherits the host data directory's ACL; POSIX exposes the file permission bits. + if (process.platform !== 'win32') + expect(statSync(join(dir, 'connection.sqlite')).mode & 0o777).toBe(0o600); + store.commit(store.read().revision, null); + expect(store.read().connection).toBeNull(); + } finally { + store.close(); + } + }); + + it('updates independent OAuth fields without changing the selected account', () => { + // Shared metadata writes preserve newer tokens; an explicit relink fences every older writer. + const first = new ConnectionStore(dir); + const second = new ConnectionStore(dir); + const connection = { + mode: 'remote' as const, + url: 'https://mcp.example/mcp', + authentication: 'oauth' as const, + authorization: { redirectUrl: 'http://127.0.0.1/callback' }, + }; + try { + const selected = first.commit(0, connection); + const tokens = { + access_token: 'synthetic-new-token', + token_type: 'Bearer', + issuer: 'https://issuer.example', + }; + first.updateAuthorization(selected.revision, { tokens }); + second.updateAuthorization(selected.revision, { client: { client_id: 'synthetic-client' } }); + expect(first.read()).toEqual({ + revision: selected.revision, + connection: { + ...connection, + authorization: { + ...connection.authorization, + tokens, + client: { client_id: 'synthetic-client' }, + }, + }, + }); + second.commit(selected.revision, connection); + expect(() => first.updateAuthorization(selected.revision, { tokens })).toThrow('changed'); + expect(second.read().connection).toEqual(connection); + } finally { + first.close(); + second.close(); + } + }); +}); diff --git a/packages/gitlab-mcp/tests/unit/cli/plugin/panel.test.ts b/packages/gitlab-mcp/tests/unit/cli/plugin/panel.test.ts new file mode 100644 index 00000000..adf79851 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/cli/plugin/panel.test.ts @@ -0,0 +1,89 @@ +import { JSDOM } from 'jsdom'; +import { setImmediate } from 'node:timers'; +import { connectionPanel } from '../../../../src/plugin/panel'; + +it('keeps secrets in the private app call and renders target strings as text', async () => { + // Setup must work before a backend exists, without turning provider strings into HTML. + const script = connectionPanel().split('')[0]; + expect(() => new Function(script)).not.toThrow(); + const calls: Array> = []; + let ready: () => void = () => {}; + const loaded = new Promise((resolve) => { + ready = resolve; + }); + const dom = new JSDOM(connectionPanel(), { + runScripts: 'dangerously', + beforeParse(window) { + window.postMessage = (message: { + id?: number; + method?: string; + params?: Record; + }) => { + if (message.id === undefined) return; + if (message.method === 'tools/call') calls.push(message.params ?? {}); + const result = + message.method === 'ui/initialize' + ? { + hostContext: { + theme: 'dark', + styles: { variables: { '--color-background-primary': '#101018' } }, + }, + } + : { + structuredContent: { + revision: 0, + state: 'unconfigured', + url: '', + authorizationUrl: null, + }, + }; + globalThis.queueMicrotask(() => { + window.dispatchEvent( + new window.MessageEvent('message', { + source: window as unknown as Window, + data: { jsonrpc: '2.0', id: message.id, result }, + }), + ); + if (message.method === 'tools/call') ready(); + }); + }; + }, + }); + try { + await loaded; + await new Promise((resolve) => setImmediate(resolve)); + const document = dom.window.document; + expect(document.documentElement.style.colorScheme).toBe('dark'); + expect(document.documentElement.style.getPropertyValue('--color-background-primary')).toBe( + '#101018', + ); + expect(document.querySelector('#status img')).toBeNull(); + expect(document.querySelector('#status')?.textContent).toContain('('#token'); + const url = document.querySelector('#url'); + if (!token || !url) throw new Error('Missing setup fields'); + expect(token.type).toBe('password'); + token.value = 'synthetic-panel-secret'; + url.value = 'https://gitlab.example'; + document + .querySelector('#form') + ?.dispatchEvent(new dom.window.Event('submit', { cancelable: true })); + await new Promise((resolve) => setImmediate(resolve)); + expect(calls.at(-1)).toMatchObject({ + name: 'gitlab_plugin_connection', + arguments: { + action: 'connect', + revision: 0, + connection: { + mode: 'local', + url: 'https://gitlab.example', + token: 'synthetic-panel-secret', + }, + }, + }); + expect(token.value).toBe(''); + expect(document.body.textContent).not.toContain('synthetic-panel-secret'); + } finally { + dom.window.close(); + } +}); diff --git a/packages/gitlab-mcp/tests/unit/cli/plugin/runtime.test.ts b/packages/gitlab-mcp/tests/unit/cli/plugin/runtime.test.ts new file mode 100644 index 00000000..f5349a24 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/cli/plugin/runtime.test.ts @@ -0,0 +1,87 @@ +import { createRequire } from 'node:module'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { collectRuntime } from '../../../../src/cli/plugin/runtime'; + +let root: string; +const write = (path: string, value: string) => { + const target = join(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, value); +}; + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'gitlab-runtime-')); + write('package.json', JSON.stringify({ name: 'fixture', version: '1.2.3' })); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +function dependency( + path: string, + name: string, + dependencies: Record, + code: string, +) { + write( + `${path}/package.json`, + JSON.stringify({ name, version: '1.0.0', dependencies, exports: { './value': './index.js' } }), + ); + write(`${path}/index.js`, code); +} + +test('the standalone runtime resolves conflicting dependencies and cyclic package metadata', () => { + // Execute the exported closure: flattening both shared versions breaks the second consumer. + write('package.json', JSON.stringify({ version: '1.2.3', dependencies: { a: '*', b: '*' } })); + write('dist/src/plugin/main.js', "module.exports = [require('a/value'), require('b/value')];"); + dependency('node_modules/a', 'a', { shared: '*' }, "module.exports = require('shared/value');"); + dependency('node_modules/b', 'b', { shared: '*' }, "module.exports = require('shared/value');"); + dependency('node_modules/shared', 'shared', { a: '*' }, "module.exports = 'first';"); + dependency('node_modules/b/node_modules/shared', 'shared', {}, "module.exports = 'second';"); + write('node_modules/a/LICENSE.md', 'dependency license'); + write('node_modules/a/README.md', 'development documentation'); + write('node_modules/a/index.js.map', '{}'); + write('node_modules/a/tests/private.js', 'not runtime code'); + write('dist/src/plugin/main.ts', 'not compiled code'); + write('LICENSE', 'root license'); + write('NOTICE', 'root notice'); + const files = new Map(); + collectRuntime(root, files); + for (const [path, bytes] of files) write(`exported/${path}`, bytes.toString()); + const requireRuntime = createRequire(join(root, 'exported/runtime/package.json')); + expect(requireRuntime('./src/plugin/main.js')).toEqual(['first', 'second']); + expect(files.get('runtime/node_modules/a/LICENSE.md')?.toString()).toBe('dependency license'); + expect(files.get('runtime/LICENSE')?.toString()).toBe('root license'); + expect(files.get('runtime/NOTICE')?.toString()).toBe('root notice'); + expect([...files.keys()].some((path) => /README|\.map$|\.ts$|\/tests\//.test(path))).toBe(false); +}); + +test('a missing compiled connector prevents packaging', () => { + // Publishing an archive without its entry point must fail before producing a runtime. + expect(() => collectRuntime(root, new Map())).toThrow('Build the core package'); +}); + +test('a missing production dependency prevents packaging', () => { + // A partial installation cannot produce a purportedly standalone archive. + write('dist/src/plugin/main.js', 'module.exports = {};'); + write('package.json', JSON.stringify({ version: '1.2.3', dependencies: { absent: '*' } })); + expect(() => collectRuntime(root, new Map())).toThrow('Cannot find runtime dependency absent'); +}); + +test('a mismatched installed package is rejected', () => { + // A directory bearing another package name must not silently satisfy the requested dependency. + write('dist/src/plugin/main.js', 'module.exports = {};'); + write('package.json', JSON.stringify({ version: '1.2.3', dependencies: { requested: '*' } })); + dependency('node_modules/requested', 'another-package', {}, 'module.exports = {};'); + expect(() => collectRuntime(root, new Map())).toThrow('Cannot find runtime dependency requested'); +}); + +test('a runtime dependency cannot introduce a filesystem link into the archive', () => { + // Junctions also work on Windows without symlink privileges; neither kind is an archive file. + write('dist/src/plugin/main.js', 'module.exports = {};'); + write('package.json', JSON.stringify({ version: '1.2.3', dependencies: { linked: '*' } })); + dependency('node_modules/linked', 'linked', {}, 'module.exports = {};'); + mkdirSync(join(root, 'target')); + symlinkSync(join(root, 'target'), join(root, 'node_modules/linked/alias'), 'junction'); + expect(() => collectRuntime(root, new Map())).toThrow('unsupported symbolic link: alias'); +}); diff --git a/packages/gitlab-mcp/tests/unit/main.test.ts b/packages/gitlab-mcp/tests/unit/main.test.ts index 248dc2d8..153da808 100644 --- a/packages/gitlab-mcp/tests/unit/main.test.ts +++ b/packages/gitlab-mcp/tests/unit/main.test.ts @@ -239,20 +239,6 @@ describe('main', () => { }); }); - describe('plugin subcommand', () => { - it('exits with the code of the plugin command', async () => { - const mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); - mockParseCliArgs.mockReturnValue({ plugin: true, pluginArgs: ['build'] }); - await runMain(); - // `build` without --endpoint is a usage error. - expect(mockConsoleError).toHaveBeenCalledWith( - expect.stringContaining('--endpoint is required'), - ); - expect(mockExit).toHaveBeenCalledWith(2); - mockConsoleError.mockRestore(); - }); - }); - describe('--show-project-config flag', () => { it('should display project config and exit 0', async () => { mockParseCliArgs.mockReturnValue({ diff --git a/packages/gitlab-mcp/tests/unit/release/codex-archive.test.ts b/packages/gitlab-mcp/tests/unit/release/codex-archive.test.ts new file mode 100644 index 00000000..c931ec81 --- /dev/null +++ b/packages/gitlab-mcp/tests/unit/release/codex-archive.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse } from 'yaml'; + +test('the prepared Codex archive is qualified before irreversible package publication', () => { + // A broken standalone archive must not leave a version already published to npm. + const workflow = parse( + readFileSync(join(__dirname, '../../../../../.github/workflows/release-please.yml'), 'utf8'), + ); + const steps = workflow.jobs['post-release'].steps as { + run?: string; + if?: unknown; + 'continue-on-error'?: unknown; + }[]; + const prepare = steps.findIndex((step) => step.run?.includes('scripts/prepare-release.sh')); + const build = steps.findIndex((step) => step.run?.includes('scripts/build-codex-plugin.mjs')); + const verify = steps.findIndex((step) => + step.run?.includes('scripts/qualify-codex-plugin.mjs --archive'), + ); + const publications = steps.flatMap((step, index) => + step.run?.includes('npm publish') ? [index] : [], + ); + expect(prepare).toBeGreaterThanOrEqual(0); + expect(build).toBeGreaterThan(prepare); + expect(verify).toBeGreaterThan(build); + expect(steps[verify].if).toBeUndefined(); + expect(steps[verify]['continue-on-error']).toBeUndefined(); + expect(publications.length).toBeGreaterThan(0); + for (const publication of publications) expect(publication).toBeGreaterThan(verify); +}); diff --git a/plugins/gitlab-mcp/mcp.json b/plugins/gitlab-mcp/mcp.json index 01bbd048..558abe16 100644 --- a/plugins/gitlab-mcp/mcp.json +++ b/plugins/gitlab-mcp/mcp.json @@ -2,8 +2,16 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", "mcpServers": { "gitlab-sw": { - "type": "streamable-http", - "url": "http://127.0.0.1:3002/mcp" + "type": "stdio", + "command": "npx", + "args": [ + "-y", + "--package", + "@structured-world/gitlab-mcp@12.1.0", + "gitlab-mcp-plugin", + "${PLUGIN_DATA}" + ], + "cwd": "${PLUGIN_ROOT}" } } } diff --git a/plugins/gitlab-mcp/skills/gitlab-setup/SKILL.md b/plugins/gitlab-mcp/skills/gitlab-setup/SKILL.md index b3e909c7..0f6d0dc8 100644 --- a/plugins/gitlab-mcp/skills/gitlab-setup/SKILL.md +++ b/plugins/gitlab-mcp/skills/gitlab-setup/SKILL.md @@ -7,6 +7,13 @@ description: Diagnose a GitLab MCP connection, selected account, token permissio Use the installed GitLab MCP tools; their prefix depends on the host and connection name. Discover the available catalog rather than assuming a fixed prefix. Keep the user's selected account and namespace. Do not request tokens, passwords, cookies, or client secrets in conversation. +If the catalog exposes `gitlab_plugin_open_connection`, use it for initial setup, +reconnect or a requested connection change. Its private panel accepts either a GitLab +address for local core execution or an existing HTTP MCP endpoint. Wait for a verified +identity and refresh discovery before using backend tools. Never pass credentials +through chat or call the app-only connection mutation tool on the user's behalf. +The backend's settings and authorization remain authoritative after connecting. + 1. Call `manage_context` with `{"action":"show"}` to identify the host, read-only mode, preset and namespace scope. This works while GitLab is disconnected. 2. Call `manage_context` with `{"action":"whoami"}` when available to inspect the connected identity, token permissions, expiry, instance version/tier and warnings. Report only what the response proves. An unavailable diagnostic is not proof of an invalid credential. 3. Explain the concrete cause: connection failure, insufficient permission, read-only policy, disabled feature, denied action, or unsupported instance capability. Distinguish these from an empty successful query. diff --git a/yarn.lock b/yarn.lock index cf311cf4..b608c8c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -406,6 +406,13 @@ __metadata: languageName: node linkType: hard +"@bcoe/v8-coverage@npm:^1.0.1": + version: 1.0.2 + resolution: "@bcoe/v8-coverage@npm:1.0.2" + checksum: 10c0/1eb1dc93cc17fb7abdcef21a6e7b867d6aa99a7ec88ec8207402b23d9083ab22a8011213f04b2cf26d535f1d22dc26139b7929e6c2134c254bd1e14ba5e678c3 + languageName: node + linkType: hard + "@bramus/specificity@npm:^2.4.2": version: 2.4.3 resolution: "@bramus/specificity@npm:2.4.3" @@ -2120,6 +2127,7 @@ __metadata: ajv: "npm:^8.20.0" auto-changelog: "npm:^2.7.0" bytes: "npm:^3.1.2" + c8: "npm:12.0.0" cross-env: "npm:^10.1.0" cross-spawn: "npm:^7.0.6" dotenv: "npm:^18.0.9" @@ -2148,6 +2156,7 @@ __metadata: zod: "npm:^4.6.7" bin: gitlab-mcp: ./dist/src/main.js + gitlab-mcp-plugin: ./dist/src/plugin/main.js languageName: unknown linkType: soft @@ -3421,6 +3430,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^6.2.2": + version: 6.5.0 + resolution: "ansi-regex@npm:6.5.0" + checksum: 10c0/778908f1af800e39f6da2167e515ecef5d7c912fd7e383520dcd80052cf1f8d57b89b6b1f31f58480a5579ab7cc48e401ac194125362d92bdeb8569ac678e3aa + languageName: node + linkType: hard + "ansi-styles@npm:4.3.0, ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -3437,6 +3453,13 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^6.2.1": + version: 6.2.3 + resolution: "ansi-styles@npm:6.2.3" + checksum: 10c0/23b8a4ce14e18fb854693b95351e286b771d23d8844057ed2e7d083cd3e708376c3323707ec6a24365f7d7eda3ca00327fe04092e29e551499ec4c8b7bfac868 + languageName: node + linkType: hard + "anymatch@npm:^3.1.3": version: 3.1.3 resolution: "anymatch@npm:3.1.3" @@ -3763,6 +3786,32 @@ __metadata: languageName: node linkType: hard +"c8@npm:12.0.0": + version: 12.0.0 + resolution: "c8@npm:12.0.0" + dependencies: + "@bcoe/v8-coverage": "npm:^1.0.1" + "@istanbuljs/schema": "npm:^0.1.3" + find-up: "npm:^5.0.0" + foreground-child: "npm:^3.1.1" + istanbul-lib-coverage: "npm:^3.2.0" + istanbul-lib-report: "npm:^3.0.1" + istanbul-reports: "npm:^3.1.6" + test-exclude: "npm:^8.0.0" + v8-to-istanbul: "npm:^9.0.0" + yargs: "npm:^18.0.0" + yargs-parser: "npm:^21.1.1" + peerDependencies: + monocart-coverage-reports: ^2 + peerDependenciesMeta: + monocart-coverage-reports: + optional: true + bin: + c8: bin/c8.js + checksum: 10c0/bd6da501c223d8700915a91e67be4c59830718ebd15125b82348cda6e3d816632539c9047b1b0b2cbc878596448e8c724e539568be23926deb95693e703f35f7 + languageName: node + linkType: hard + "cacheable@npm:^2.5.0": version: 2.5.0 resolution: "cacheable@npm:2.5.0" @@ -3933,6 +3982,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^9.0.1": + version: 9.0.1 + resolution: "cliui@npm:9.0.1" + dependencies: + string-width: "npm:^7.2.0" + strip-ansi: "npm:^7.1.0" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/13441832e9efe7c7a76bd2b8e683555c478d461a9f249dc5db9b17fe8d4b47fa9277b503914b90bd00e4a151abb6b9b02b2288972ffe2e5e3ca40bcb1c2330d3 + languageName: node + linkType: hard + "clone@npm:1.0.4, clone@npm:^1.0.2": version: 1.0.4 resolution: "clone@npm:1.0.4" @@ -4491,6 +4551,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^10.3.0": + version: 10.6.0 + resolution: "emoji-regex@npm:10.6.0" + checksum: 10c0/1e4aa097bb007301c3b4b1913879ae27327fdc48e93eeefefe3b87e495eb33c5af155300be951b4349ff6ac084f4403dc9eff970acba7c1c572d89396a9a32d7 + languageName: node + linkType: hard + "empathic@npm:2.0.0": version: 2.0.0 resolution: "empathic@npm:2.0.0" @@ -5109,7 +5176,7 @@ __metadata: languageName: node linkType: hard -"foreground-child@npm:3.3.1": +"foreground-child@npm:3.3.1, foreground-child@npm:^3.1.1": version: 3.3.1 resolution: "foreground-child@npm:3.3.1" dependencies: @@ -5196,6 +5263,13 @@ __metadata: languageName: node linkType: hard +"get-east-asian-width@npm:^1.0.0, get-east-asian-width@npm:^1.5.0": + version: 1.7.0 + resolution: "get-east-asian-width@npm:1.7.0" + checksum: 10c0/680a5f611a186e4a065291f31821f7e4ad7742625be814e05b8ab5535c6fa53562bc0333a83138fca5f2e1030ed960a561b2fd4629e0cff0dc68b19cf46d7c53 + languageName: node + linkType: hard + "get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" @@ -5738,7 +5812,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-report@npm:^3.0.0": +"istanbul-lib-report@npm:^3.0.0, istanbul-lib-report@npm:^3.0.1": version: 3.0.1 resolution: "istanbul-lib-report@npm:3.0.1" dependencies: @@ -5760,7 +5834,7 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.1.3": +"istanbul-reports@npm:^3.1.3, istanbul-reports@npm:^3.1.6": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" dependencies: @@ -8272,6 +8346,27 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^7.0.0, string-width@npm:^7.2.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + +"string-width@npm:^8.2.1": + version: 8.3.0 + resolution: "string-width@npm:8.3.0" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10c0/bc354b27772777c314e6792106f01586e959f36abb3d4b8ad46c4d4ee92d5e906acb10c1f3be97a85ae0ba1b11389094bc5672999fc5f03f12d22a4725fa30fe + languageName: node + linkType: hard + "string_decoder@npm:1.3.0, string_decoder@npm:^1.1.1": version: 1.3.0 resolution: "string_decoder@npm:1.3.0" @@ -8300,6 +8395,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^7.1.0, strip-ansi@npm:^7.1.2": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" + dependencies: + ansi-regex: "npm:^6.2.2" + checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221 + languageName: node + linkType: hard + "strip-bom@npm:3.0.0, strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "strip-bom@npm:3.0.0" @@ -8873,7 +8977,7 @@ __metadata: languageName: node linkType: hard -"v8-to-istanbul@npm:^9.0.1": +"v8-to-istanbul@npm:^9.0.0, v8-to-istanbul@npm:^9.0.1": version: 9.3.0 resolution: "v8-to-istanbul@npm:9.3.0" dependencies: @@ -9134,6 +9238,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^9.0.0": + version: 9.0.2 + resolution: "wrap-ansi@npm:9.0.2" + dependencies: + ansi-styles: "npm:^6.2.1" + string-width: "npm:^7.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/3305839b9a0d6fb930cb63a52f34d3936013d8b0682ff3ec133c9826512620f213800ffa19ea22904876d5b7e9a3c1f40682f03597d986a4ca881fa7b033688c + languageName: node + linkType: hard + "wrappy@npm:1, wrappy@npm:1.0.2": version: 1.0.2 resolution: "wrappy@npm:1.0.2" @@ -9235,6 +9350,13 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^22.0.0": + version: 22.0.0 + resolution: "yargs-parser@npm:22.0.0" + checksum: 10c0/cb7ef81759c4271cb1d96b9351dbbc9a9ce35d3e1122d2b739bf6c432603824fa02c67cc12dcef6ea80283379d63495686e8f41cc7b06c6576e792aba4d33e1c + languageName: node + linkType: hard + "yargs@npm:17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" @@ -9265,6 +9387,20 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^18.0.0": + version: 18.2.0 + resolution: "yargs@npm:18.2.0" + dependencies: + cliui: "npm:^9.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + string-width: "npm:^8.2.1" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^22.0.0" + checksum: 10c0/7419338c3c00f9143ec6b6ab168335061c6f5342d161642aec23ff119e28cac1c417e0eca1d7dc82fd084f07207cc5d34c7d8315f605c0e3dbf12c350e405f3f + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1"