diff --git a/.github/workflows/mandoline-mcp.yaml b/.github/workflows/mandoline-mcp.yaml new file mode 100644 index 0000000..6a25262 --- /dev/null +++ b/.github/workflows/mandoline-mcp.yaml @@ -0,0 +1,26 @@ +name: Mandoline MCP tests +on: + pull_request: + paths: + - "modules/mandoline-mcp/**" + - ".github/workflows/mandoline-mcp.yaml" + push: + branches: [main] + paths: + - "modules/mandoline-mcp/**" + - ".github/workflows/mandoline-mcp.yaml" + +jobs: + terraform: + runs-on: ubuntu-latest + defaults: + run: + working-directory: modules/mandoline-mcp + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3 + with: + terraform_version: "1.13.5" + - run: terraform fmt -check + - run: terraform init -backend=false + - run: terraform test diff --git a/README.md b/README.md index 7f5d4e2..033a63b 100644 --- a/README.md +++ b/README.md @@ -130,3 +130,17 @@ module "slack_mcp" { Register the exported launcher command in your clients. Choose `read-write` to expose all tools permitted by the Slack grant. See the module README for Codex environment forwarding and runtime requirements. + +## `mandoline-mcp` + +[`modules/mandoline-mcp`](modules/mandoline-mcp) installs Mandoline on the +agent PATH so clients can run `mandoline mcp` as a local stdio MCP server. +The startup script downloads the Linux x86_64 release from GitHub. The calling +template registers `mandoline` with args `["mcp"]` in its coding clients. + +```hcl +module "mandoline_mcp" { + source = "./modules/mandoline-mcp" + agent_id = coder_agent.main.id +} +``` diff --git a/modules/mandoline-mcp/README.md b/modules/mandoline-mcp/README.md new file mode 100644 index 0000000..7bcbe4b --- /dev/null +++ b/modules/mandoline-mcp/README.md @@ -0,0 +1,82 @@ +# Mandoline MCP + +Installs [Mandoline](https://github.com/semgrep/mandoline) on the agent PATH +so coding clients can run `mandoline mcp` as a stdio MCP server. The startup +script downloads the Linux x86_64 release archive from the private Mandoline +GitHub repository using the authenticated GitHub CLI. + +## Usage + +```hcl +module "mandoline_mcp" { + source = "./modules/mandoline-mcp" + agent_id = coder_agent.main.id + # Optional: pin an exact release instead of latest. + # mandoline_version = "0.15.0" +} +``` + +The module installs `mandoline` in `/usr/local/bin` and prepends that directory to +the agent PATH. Register `module.mandoline_mcp.launcher_command` with +`module.mandoline_mcp.launcher_args` in your clients' MCP configuration. For +example, Claude Code accepts: + +```json +{ + "mcpServers": { + "mandoline": { "command": "mandoline", "args": ["mcp"] } + } +} +``` + +Codex accepts this TOML: + +```toml +[mcp_servers.mandoline] +command = "mandoline" +args = ["mcp"] +``` + +Client registration remains the calling template's responsibility. Registration +applies to the agent on which the module runs; clients in another container need +access to the installed Mandoline binary. + +## Installation + +The agent image must provide `gh`, `tar`, and a POSIX shell, and the agent user +must be able to write to `/usr/local/bin`. Because Mandoline releases are private, +`gh` must already be authenticated to GitHub with access to +`semgrep/mandoline` (for example via `GH_TOKEN` or `gh auth login`). + +The installer targets Linux x86_64 (`amd64`) only. For `latest`, it resolves +the newest non-draft, non-prerelease with `gh release list`, then runs +`gh release download` for `mandoline-linux-x86_64.tar.gz`. It extracts the +archive in a temporary directory, installs `/usr/local/bin/mandoline`, and removes +the temporary files. + +Pin an exact semantic version such as `0.15.0` to download the matching +`v0.15.0` release tag and reinstall when that pin changes. Changing the +selector, or removing `/usr/local/bin/mandoline` (and `/usr/local/bin/mandoline.version`), +refreshes the binary on the next workspace start. + +Mandoline analyzes the client's working directory; no Coder external +authentication is required for the MCP server itself. + +| Input | Default | Description | +| --- | --- | --- | +| `agent_id` | Required | Coder agent receiving the installer and PATH entry | +| `mandoline_version` | `latest` | `latest` or an exact release such as `0.15.0` | + +Outputs: `launcher_command` is `mandoline`; `launcher_args` is `["mcp"]`; +`launcher_path` is `/usr/local/bin/mandoline`; `mandoline_version` echoes the +configured selector. + +## Validation + +```sh +cd modules/mandoline-mcp +terraform init -backend=false +terraform test +``` + +Terraform tests use a mock Coder provider. diff --git a/modules/mandoline-mcp/main.tf b/modules/mandoline-mcp/main.tf new file mode 100644 index 0000000..76fc29a --- /dev/null +++ b/modules/mandoline-mcp/main.tf @@ -0,0 +1,25 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +resource "coder_env" "mandoline_mcp_path" { + agent_id = var.agent_id + name = "PATH" + value = "/usr/local/bin:$PATH" + merge_strategy = "prepend" +} + +resource "coder_script" "mandoline_mcp" { + agent_id = var.agent_id + display_name = "Install Mandoline MCP" + run_on_start = true + start_blocks_login = true + timeout = 300 + script = templatefile("${path.module}/scripts/install.sh.tpl", { + mandoline_version = var.mandoline_version + }) +} diff --git a/modules/mandoline-mcp/main.tftest.hcl b/modules/mandoline-mcp/main.tftest.hcl new file mode 100644 index 0000000..965c7c5 --- /dev/null +++ b/modules/mandoline-mcp/main.tftest.hcl @@ -0,0 +1,61 @@ +mock_provider "coder" {} + +run "defaults" { + command = plan + variables { + agent_id = "test-agent" + } + assert { + condition = output.launcher_command == "mandoline" + error_message = "Clients must receive the mandoline binary as the MCP command." + } + assert { + condition = length(output.launcher_args) == 1 && output.launcher_args[0] == "mcp" + error_message = "Clients must receive mcp as the Mandoline MCP argument." + } + assert { + condition = output.launcher_path == "/usr/local/bin/mandoline" + error_message = "Clients must receive the /usr/local/bin Mandoline binary path." + } + assert { + condition = coder_env.mandoline_mcp_path.name == "PATH" && coder_env.mandoline_mcp_path.merge_strategy == "prepend" && coder_env.mandoline_mcp_path.value == "/usr/local/bin:$PATH" + error_message = "The Mandoline install directory must be prepended to PATH." + } + assert { + condition = strcontains(coder_script.mandoline_mcp.script, "gh release list --repo \"$repo\"") && strcontains(coder_script.mandoline_mcp.script, "gh release download \"$release\" --repo \"$repo\" --pattern \"$asset\"") && strcontains(coder_script.mandoline_mcp.script, "repo='semgrep/mandoline'") + error_message = "The installer must download private Mandoline releases with an authenticated gh CLI." + } + assert { + condition = strcontains(coder_script.mandoline_mcp.script, "install_dir=\"/usr/local/bin\"") && strcontains(coder_script.mandoline_mcp.script, "mktemp -d") + error_message = "The installer must install into /usr/local/bin and stage downloads with mktemp -d." + } +} + +run "pinned_version" { + command = plan + variables { + agent_id = "test-agent" + mandoline_version = "0.15.0" + } + assert { + condition = strcontains(coder_script.mandoline_mcp.script, "selector='0.15.0'") + error_message = "A pinned version must be recorded for idempotent upgrades." + } + assert { + condition = strcontains(coder_script.mandoline_mcp.script, "release=\"v$${selector}\"") + error_message = "A pinned version must download the matching Mandoline release tag." + } + assert { + condition = output.mandoline_version == "0.15.0" + error_message = "The configured Mandoline version must be exported." + } +} + +run "reject_invalid_version" { + command = plan + variables { + agent_id = "test-agent" + mandoline_version = "v0.15.0" + } + expect_failures = [var.mandoline_version] +} diff --git a/modules/mandoline-mcp/outputs.tf b/modules/mandoline-mcp/outputs.tf new file mode 100644 index 0000000..a2a90c6 --- /dev/null +++ b/modules/mandoline-mcp/outputs.tf @@ -0,0 +1,19 @@ +output "launcher_command" { + description = "MCP stdio command on the agent PATH. Pair with launcher_args." + value = "mandoline" +} + +output "launcher_args" { + description = "Arguments that start Mandoline as an MCP stdio server." + value = ["mcp"] +} + +output "launcher_path" { + description = "Installed Mandoline binary path." + value = "/usr/local/bin/mandoline" +} + +output "mandoline_version" { + description = "Configured Mandoline release selector used by the installer." + value = var.mandoline_version +} diff --git a/modules/mandoline-mcp/scripts/install.sh.tpl b/modules/mandoline-mcp/scripts/install.sh.tpl new file mode 100644 index 0000000..40fd546 --- /dev/null +++ b/modules/mandoline-mcp/scripts/install.sh.tpl @@ -0,0 +1,63 @@ +#!/bin/sh +# Download Mandoline via gh and place it on the agent PATH for `mandoline mcp`. +set -eu +umask 077 + +install_dir="/usr/local/bin" +version_file="$install_dir/mandoline.version" +selector='${mandoline_version}' +asset='mandoline-linux-x86_64.tar.gz' +repo='semgrep/mandoline' + +fail() { + echo "Mandoline MCP: $1" >&2 + exit 1 +} + +command -v gh >/dev/null 2>&1 || fail 'GitHub CLI (gh) is required to download Mandoline.' +command -v tar >/dev/null 2>&1 || fail 'tar is required to extract Mandoline.' + +case "$(uname -s)" in + Linux) ;; + *) fail "Unsupported operating system for Mandoline MCP: $(uname -s)." ;; +esac + +case "$(uname -m)" in + x86_64|amd64) ;; + *) fail "Unsupported architecture for Mandoline MCP: $(uname -m). Expected x86_64." ;; +esac + +if [ "$selector" = latest ]; then + release="$(gh release list --repo "$repo" --exclude-drafts --exclude-pre-releases --limit 1 --json name --jq '.[0].name')" \ + || fail "Could not list Mandoline releases from $repo." + [ -n "$release" ] && [ "$release" != null ] \ + || fail "No Mandoline releases found in $repo." +else + release="v$${selector}" +fi + +mkdir -p "$install_dir" + +if [ ! -x "$install_dir/mandoline" ] || [ "$(cat "$version_file" 2>/dev/null || true)" != "$release" ]; then + staging="$(mktemp -d)" + trap 'rm -rf "$staging"' EXIT HUP INT TERM + echo "Downloading Mandoline ($release)…" + ( + cd "$staging" + gh release download "$release" --repo "$repo" --pattern "$asset" \ + || fail "Could not download Mandoline release $release from $repo." + tar -xf "$asset" \ + || fail 'Could not extract the Mandoline archive.' + [ -f mandoline ] || fail 'Mandoline archive did not contain a mandoline binary.' + chmod 0755 mandoline + mv mandoline "$install_dir/mandoline" + rm -f "$asset" + ) + printf '%s\n' "$release" > "$version_file" + chmod 600 "$version_file" + rm -rf "$staging" + trap - EXIT HUP INT TERM +fi + +[ -x "$install_dir/mandoline" ] || fail 'Mandoline installation finished without an executable binary.' +echo "Mandoline MCP installed ($release); use \`mandoline mcp\`." diff --git a/modules/mandoline-mcp/variables.tf b/modules/mandoline-mcp/variables.tf new file mode 100644 index 0000000..35f0f05 --- /dev/null +++ b/modules/mandoline-mcp/variables.tf @@ -0,0 +1,15 @@ +variable "agent_id" { + description = "Coder agent on which to install the Mandoline MCP launcher." + type = string +} + +variable "mandoline_version" { + description = "Desired Mandoline release, or latest to install from the GitHub latest release asset." + type = string + default = "latest" + + validation { + condition = var.mandoline_version == "latest" || can(regex("^[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?$", var.mandoline_version)) + error_message = "mandoline_version must be latest or an exact semantic version such as 0.17.0." + } +}