From b636b56905844946811df5da0a19aace009e79d1 Mon Sep 17 00:00:00 2001 From: Zach Yannes Date: Thu, 15 Oct 2026 11:17:01 -0400 Subject: [PATCH 1/7] feat(mandoline-mcp): add Mandoline MCP installer module Download the Linux x86_64 Mandoline release onto the agent PATH so clients can run `mandoline mcp` as a local stdio MCP server. Co-authored-by: Cursor --- .github/workflows/mandoline-mcp.yaml | 26 +++++++ README.md | 14 ++++ modules/mandoline-mcp/README.md | 77 ++++++++++++++++++++ modules/mandoline-mcp/main.tf | 25 +++++++ modules/mandoline-mcp/main.tftest.hcl | 61 ++++++++++++++++ modules/mandoline-mcp/outputs.tf | 19 +++++ modules/mandoline-mcp/scripts/install.sh.tpl | 60 +++++++++++++++ modules/mandoline-mcp/variables.tf | 15 ++++ 8 files changed, 297 insertions(+) create mode 100644 .github/workflows/mandoline-mcp.yaml create mode 100644 modules/mandoline-mcp/README.md create mode 100644 modules/mandoline-mcp/main.tf create mode 100644 modules/mandoline-mcp/main.tftest.hcl create mode 100644 modules/mandoline-mcp/outputs.tf create mode 100644 modules/mandoline-mcp/scripts/install.sh.tpl create mode 100644 modules/mandoline-mcp/variables.tf 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..e489e61 --- /dev/null +++ b/modules/mandoline-mcp/README.md @@ -0,0 +1,77 @@ +# 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 GitHub. + +## 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 `$HOME/.local/bin` and adds that location 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 `curl`, `tar`, and a POSIX shell. Initial +installation needs outbound HTTPS access to GitHub releases. The installer +targets Linux x86_64 (`amd64`) only and extracts the published +`mandoline-linux-x86_64.tar.gz` asset. + +By default `mandoline_version = "latest"` downloads from +`https://github.com/semgrep/mandoline/releases/latest/download/mandoline-linux-x86_64.tar.gz`. +Pin an exact semantic version to install from the matching tagged release and +to reinstall when that pin changes. Changing the selector, or removing +`$HOME/.local/share/coder-mandoline-mcp`, refreshes the binary on the next +workspace start. + +Mandoline analyzes the client's working directory; no Coder external +authentication is required. + +| 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 the `$HOME`-relative binary path (expand HOME before using +it as an absolute command); `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..a4b6686 --- /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 = "$HOME/.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..323a9cd --- /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 == "$HOME/.local/bin/mandoline" + error_message = "Clients must receive the user-scoped Mandoline binary path." + } + assert { + condition = coder_env.mandoline_mcp_path.name == "PATH" && coder_env.mandoline_mcp_path.merge_strategy == "prepend" + error_message = "The Mandoline install directory must be prepended to PATH." + } + assert { + condition = strcontains(coder_script.mandoline_mcp.script, "https://github.com/semgrep/mandoline/releases/latest/download/$asset") && strcontains(coder_script.mandoline_mcp.script, "asset='mandoline-linux-x86_64.tar.gz'") + error_message = "The default installer must download the latest Linux x86_64 release asset." + } + assert { + condition = strcontains(coder_script.mandoline_mcp.script, "\"$install_dir/mandoline\"") && !strcontains(coder_script.mandoline_mcp.script, "coder-mandoline-mcp <<") + error_message = "The installer must place mandoline on PATH without a wrapper launcher." + } +} + +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, "https://github.com/semgrep/mandoline/releases/download/v$${selector}/$asset") + error_message = "A pinned version must download from the matching GitHub 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..976d05f --- /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 = "User-scoped Mandoline binary location. Expand HOME before using this as an absolute client command." + value = "$HOME/.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..0f74987 --- /dev/null +++ b/modules/mandoline-mcp/scripts/install.sh.tpl @@ -0,0 +1,60 @@ +#!/bin/sh +# Download Mandoline and place it on the agent PATH for `mandoline mcp`. +set -eu +umask 077 + +state_dir="$HOME/.local/share/coder-mandoline-mcp" +install_dir="$HOME/.local/bin" +version_file="$state_dir/version" +selector='${mandoline_version}' +asset='mandoline-linux-x86_64.tar.gz' + +fail() { + echo "Mandoline MCP: $1" >&2 + exit 1 +} + +command -v curl >/dev/null 2>&1 || fail 'curl 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 + download_url="https://github.com/semgrep/mandoline/releases/latest/download/$asset" +else + download_url="https://github.com/semgrep/mandoline/releases/download/v$${selector}/$asset" +fi + +mkdir -p "$state_dir" "$install_dir" +chmod 700 "$state_dir" + +if [ ! -x "$install_dir/mandoline" ] || [ "$(cat "$version_file" 2>/dev/null || true)" != "$selector" ]; then + staging="$(mktemp -d "$state_dir/download.XXXXXX")" + trap 'rm -rf "$staging"' EXIT HUP INT TERM + echo "Downloading Mandoline ($selector)…" + curl --fail --silent --show-error --location --proto '=https' \ + "$download_url" -o "$staging/$asset" \ + || fail "Could not download Mandoline from $download_url." + tar -xzf "$staging/$asset" -C "$staging" \ + || fail 'Could not extract the Mandoline archive.' + binary="$(find "$staging" -type f -name mandoline | head -n 1)" + [ -n "$binary" ] || fail 'Mandoline archive did not contain a mandoline binary.' + install -m 0755 "$binary" "$install_dir/mandoline" + printf '%s\n' "$selector" > "$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.' +# Remove a prior wrapper if an older module revision left one behind. +rm -f "$install_dir/coder-mandoline-mcp" +echo "Mandoline MCP installed ($selector); 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." + } +} From 93b178b56fb9efe4e18aa0e830ae39c8cad65850 Mon Sep 17 00:00:00 2001 From: Zach Yannes Date: Thu, 15 Oct 2026 11:23:17 -0400 Subject: [PATCH 2/7] fix(mandoline-mcp): drop remaining coder-mandoline-mcp naming Use a mandoline state directory and install only the mandoline binary on PATH. Co-authored-by: Cursor --- modules/mandoline-mcp/README.md | 2 +- modules/mandoline-mcp/main.tftest.hcl | 4 ++-- modules/mandoline-mcp/scripts/install.sh.tpl | 4 +--- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/modules/mandoline-mcp/README.md b/modules/mandoline-mcp/README.md index e489e61..8eea49b 100644 --- a/modules/mandoline-mcp/README.md +++ b/modules/mandoline-mcp/README.md @@ -51,7 +51,7 @@ By default `mandoline_version = "latest"` downloads from `https://github.com/semgrep/mandoline/releases/latest/download/mandoline-linux-x86_64.tar.gz`. Pin an exact semantic version to install from the matching tagged release and to reinstall when that pin changes. Changing the selector, or removing -`$HOME/.local/share/coder-mandoline-mcp`, refreshes the binary on the next +`$HOME/.local/share/mandoline`, refreshes the binary on the next workspace start. Mandoline analyzes the client's working directory; no Coder external diff --git a/modules/mandoline-mcp/main.tftest.hcl b/modules/mandoline-mcp/main.tftest.hcl index 323a9cd..c41b2e1 100644 --- a/modules/mandoline-mcp/main.tftest.hcl +++ b/modules/mandoline-mcp/main.tftest.hcl @@ -26,8 +26,8 @@ run "defaults" { error_message = "The default installer must download the latest Linux x86_64 release asset." } assert { - condition = strcontains(coder_script.mandoline_mcp.script, "\"$install_dir/mandoline\"") && !strcontains(coder_script.mandoline_mcp.script, "coder-mandoline-mcp <<") - error_message = "The installer must place mandoline on PATH without a wrapper launcher." + condition = strcontains(coder_script.mandoline_mcp.script, "\"$install_dir/mandoline\"") + error_message = "The installer must place mandoline on PATH." } } diff --git a/modules/mandoline-mcp/scripts/install.sh.tpl b/modules/mandoline-mcp/scripts/install.sh.tpl index 0f74987..f3f5ba8 100644 --- a/modules/mandoline-mcp/scripts/install.sh.tpl +++ b/modules/mandoline-mcp/scripts/install.sh.tpl @@ -3,7 +3,7 @@ set -eu umask 077 -state_dir="$HOME/.local/share/coder-mandoline-mcp" +state_dir="$HOME/.local/share/mandoline" install_dir="$HOME/.local/bin" version_file="$state_dir/version" selector='${mandoline_version}' @@ -55,6 +55,4 @@ if [ ! -x "$install_dir/mandoline" ] || [ "$(cat "$version_file" 2>/dev/null || fi [ -x "$install_dir/mandoline" ] || fail 'Mandoline installation finished without an executable binary.' -# Remove a prior wrapper if an older module revision left one behind. -rm -f "$install_dir/coder-mandoline-mcp" echo "Mandoline MCP installed ($selector); use \`mandoline mcp\`." From 114173f884ad7c718fea7ff42e0a53dd4d8325ff Mon Sep 17 00:00:00 2001 From: Zach Yannes Date: Thu, 15 Oct 2026 11:39:46 -0400 Subject: [PATCH 3/7] fix(mandoline-mcp): install to /opt and stage downloads in /tmp Keep the binary off the mounted HOME volume; use /tmp only for the download extract. Co-authored-by: Cursor --- modules/mandoline-mcp/README.md | 21 ++++++++++---------- modules/mandoline-mcp/main.tf | 2 +- modules/mandoline-mcp/main.tftest.hcl | 10 +++++----- modules/mandoline-mcp/outputs.tf | 4 ++-- modules/mandoline-mcp/scripts/install.sh.tpl | 11 +++++----- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/modules/mandoline-mcp/README.md b/modules/mandoline-mcp/README.md index 8eea49b..16d471d 100644 --- a/modules/mandoline-mcp/README.md +++ b/modules/mandoline-mcp/README.md @@ -15,8 +15,8 @@ module "mandoline_mcp" { } ``` -The module installs `mandoline` in `$HOME/.local/bin` and adds that location to -the agent PATH. Register `module.mandoline_mcp.launcher_command` with +The module installs `mandoline` in `/opt` 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: @@ -42,17 +42,18 @@ access to the installed Mandoline binary. ## Installation -The agent image must provide `curl`, `tar`, and a POSIX shell. Initial -installation needs outbound HTTPS access to GitHub releases. The installer -targets Linux x86_64 (`amd64`) only and extracts the published -`mandoline-linux-x86_64.tar.gz` asset. +The agent image must provide `curl`, `tar`, and a POSIX shell, and the agent +user must be able to write to `/opt`. Initial installation needs outbound HTTPS +access to GitHub releases. The installer targets Linux x86_64 (`amd64`) only +and extracts the published `mandoline-linux-x86_64.tar.gz` asset into a +temporary directory under `/tmp` before installing the binary. By default `mandoline_version = "latest"` downloads from `https://github.com/semgrep/mandoline/releases/latest/download/mandoline-linux-x86_64.tar.gz`. Pin an exact semantic version to install from the matching tagged release and to reinstall when that pin changes. Changing the selector, or removing -`$HOME/.local/share/mandoline`, refreshes the binary on the next -workspace start. +`/opt/mandoline` (and `/opt/mandoline.version`), refreshes the binary on the +next workspace start. Mandoline analyzes the client's working directory; no Coder external authentication is required. @@ -63,8 +64,8 @@ authentication is required. | `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 the `$HOME`-relative binary path (expand HOME before using -it as an absolute command); `mandoline_version` echoes the configured selector. +`launcher_path` is `/opt/mandoline`; `mandoline_version` echoes the configured +selector. ## Validation diff --git a/modules/mandoline-mcp/main.tf b/modules/mandoline-mcp/main.tf index a4b6686..3543903 100644 --- a/modules/mandoline-mcp/main.tf +++ b/modules/mandoline-mcp/main.tf @@ -9,7 +9,7 @@ terraform { resource "coder_env" "mandoline_mcp_path" { agent_id = var.agent_id name = "PATH" - value = "$HOME/.local/bin:$PATH" + value = "/opt:$PATH" merge_strategy = "prepend" } diff --git a/modules/mandoline-mcp/main.tftest.hcl b/modules/mandoline-mcp/main.tftest.hcl index c41b2e1..d937a28 100644 --- a/modules/mandoline-mcp/main.tftest.hcl +++ b/modules/mandoline-mcp/main.tftest.hcl @@ -14,11 +14,11 @@ run "defaults" { error_message = "Clients must receive mcp as the Mandoline MCP argument." } assert { - condition = output.launcher_path == "$HOME/.local/bin/mandoline" - error_message = "Clients must receive the user-scoped Mandoline binary path." + condition = output.launcher_path == "/opt/mandoline" + error_message = "Clients must receive the /opt Mandoline binary path." } assert { - condition = coder_env.mandoline_mcp_path.name == "PATH" && coder_env.mandoline_mcp_path.merge_strategy == "prepend" + condition = coder_env.mandoline_mcp_path.name == "PATH" && coder_env.mandoline_mcp_path.merge_strategy == "prepend" && coder_env.mandoline_mcp_path.value == "/opt:$PATH" error_message = "The Mandoline install directory must be prepended to PATH." } assert { @@ -26,8 +26,8 @@ run "defaults" { error_message = "The default installer must download the latest Linux x86_64 release asset." } assert { - condition = strcontains(coder_script.mandoline_mcp.script, "\"$install_dir/mandoline\"") - error_message = "The installer must place mandoline on PATH." + condition = strcontains(coder_script.mandoline_mcp.script, "install_dir=\"/opt\"") && strcontains(coder_script.mandoline_mcp.script, "state_dir=\"/tmp\"") + error_message = "The installer must install into /opt and stage downloads in /tmp." } } diff --git a/modules/mandoline-mcp/outputs.tf b/modules/mandoline-mcp/outputs.tf index 976d05f..ad62a86 100644 --- a/modules/mandoline-mcp/outputs.tf +++ b/modules/mandoline-mcp/outputs.tf @@ -9,8 +9,8 @@ output "launcher_args" { } output "launcher_path" { - description = "User-scoped Mandoline binary location. Expand HOME before using this as an absolute client command." - value = "$HOME/.local/bin/mandoline" + description = "Installed Mandoline binary path." + value = "/opt/mandoline" } output "mandoline_version" { diff --git a/modules/mandoline-mcp/scripts/install.sh.tpl b/modules/mandoline-mcp/scripts/install.sh.tpl index f3f5ba8..69fd83d 100644 --- a/modules/mandoline-mcp/scripts/install.sh.tpl +++ b/modules/mandoline-mcp/scripts/install.sh.tpl @@ -3,9 +3,9 @@ set -eu umask 077 -state_dir="$HOME/.local/share/mandoline" -install_dir="$HOME/.local/bin" -version_file="$state_dir/version" +state_dir="/tmp" +install_dir="/opt" +version_file="$install_dir/mandoline.version" selector='${mandoline_version}' asset='mandoline-linux-x86_64.tar.gz' @@ -33,11 +33,10 @@ else download_url="https://github.com/semgrep/mandoline/releases/download/v$${selector}/$asset" fi -mkdir -p "$state_dir" "$install_dir" -chmod 700 "$state_dir" +mkdir -p "$install_dir" if [ ! -x "$install_dir/mandoline" ] || [ "$(cat "$version_file" 2>/dev/null || true)" != "$selector" ]; then - staging="$(mktemp -d "$state_dir/download.XXXXXX")" + staging="$(mktemp -d "$state_dir/mandoline.XXXXXX")" trap 'rm -rf "$staging"' EXIT HUP INT TERM echo "Downloading Mandoline ($selector)…" curl --fail --silent --show-error --location --proto '=https' \ From 4191762b76e4b7d8398f5b15166c554b29a54f60 Mon Sep 17 00:00:00 2001 From: Zach Yannes Date: Thu, 15 Oct 2026 11:46:52 -0400 Subject: [PATCH 4/7] fix(mandoline-mcp): install into /opt/bin with a simpler download flow Stage the archive via mktemp -d, install the binary to /opt/bin/mandoline, and prepend /opt/bin to PATH. Co-authored-by: Cursor --- modules/mandoline-mcp/README.md | 21 ++++++++++---------- modules/mandoline-mcp/main.tf | 2 +- modules/mandoline-mcp/main.tftest.hcl | 10 +++++----- modules/mandoline-mcp/outputs.tf | 2 +- modules/mandoline-mcp/scripts/install.sh.tpl | 8 ++++---- 5 files changed, 22 insertions(+), 21 deletions(-) diff --git a/modules/mandoline-mcp/README.md b/modules/mandoline-mcp/README.md index 16d471d..afb6945 100644 --- a/modules/mandoline-mcp/README.md +++ b/modules/mandoline-mcp/README.md @@ -15,8 +15,8 @@ module "mandoline_mcp" { } ``` -The module installs `mandoline` in `/opt` and prepends that directory to the -agent PATH. Register `module.mandoline_mcp.launcher_command` with +The module installs `mandoline` in `/opt/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: @@ -43,17 +43,18 @@ access to the installed Mandoline binary. ## Installation The agent image must provide `curl`, `tar`, and a POSIX shell, and the agent -user must be able to write to `/opt`. Initial installation needs outbound HTTPS -access to GitHub releases. The installer targets Linux x86_64 (`amd64`) only -and extracts the published `mandoline-linux-x86_64.tar.gz` asset into a -temporary directory under `/tmp` before installing the binary. +user must be able to write to `/opt/bin`. Initial installation needs outbound +HTTPS access to GitHub releases. The installer targets Linux x86_64 (`amd64`) +only: it creates a temporary directory, downloads and extracts +`mandoline-linux-x86_64.tar.gz`, installs the binary to `/opt/bin/mandoline`, +then removes the temporary directory. By default `mandoline_version = "latest"` downloads from `https://github.com/semgrep/mandoline/releases/latest/download/mandoline-linux-x86_64.tar.gz`. Pin an exact semantic version to install from the matching tagged release and to reinstall when that pin changes. Changing the selector, or removing -`/opt/mandoline` (and `/opt/mandoline.version`), refreshes the binary on the -next workspace start. +`/opt/bin/mandoline` (and `/opt/bin/mandoline.version`), refreshes the binary +on the next workspace start. Mandoline analyzes the client's working directory; no Coder external authentication is required. @@ -64,8 +65,8 @@ authentication is required. | `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 `/opt/mandoline`; `mandoline_version` echoes the configured -selector. +`launcher_path` is `/opt/bin/mandoline`; `mandoline_version` echoes the +configured selector. ## Validation diff --git a/modules/mandoline-mcp/main.tf b/modules/mandoline-mcp/main.tf index 3543903..8cb6042 100644 --- a/modules/mandoline-mcp/main.tf +++ b/modules/mandoline-mcp/main.tf @@ -9,7 +9,7 @@ terraform { resource "coder_env" "mandoline_mcp_path" { agent_id = var.agent_id name = "PATH" - value = "/opt:$PATH" + value = "/opt/bin:$PATH" merge_strategy = "prepend" } diff --git a/modules/mandoline-mcp/main.tftest.hcl b/modules/mandoline-mcp/main.tftest.hcl index d937a28..f7586f3 100644 --- a/modules/mandoline-mcp/main.tftest.hcl +++ b/modules/mandoline-mcp/main.tftest.hcl @@ -14,11 +14,11 @@ run "defaults" { error_message = "Clients must receive mcp as the Mandoline MCP argument." } assert { - condition = output.launcher_path == "/opt/mandoline" - error_message = "Clients must receive the /opt Mandoline binary path." + condition = output.launcher_path == "/opt/bin/mandoline" + error_message = "Clients must receive the /opt/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 == "/opt:$PATH" + condition = coder_env.mandoline_mcp_path.name == "PATH" && coder_env.mandoline_mcp_path.merge_strategy == "prepend" && coder_env.mandoline_mcp_path.value == "/opt/bin:$PATH" error_message = "The Mandoline install directory must be prepended to PATH." } assert { @@ -26,8 +26,8 @@ run "defaults" { error_message = "The default installer must download the latest Linux x86_64 release asset." } assert { - condition = strcontains(coder_script.mandoline_mcp.script, "install_dir=\"/opt\"") && strcontains(coder_script.mandoline_mcp.script, "state_dir=\"/tmp\"") - error_message = "The installer must install into /opt and stage downloads in /tmp." + condition = strcontains(coder_script.mandoline_mcp.script, "install_dir=\"/opt/bin\"") && strcontains(coder_script.mandoline_mcp.script, "mktemp -d") + error_message = "The installer must install into /opt/bin and stage downloads with mktemp -d." } } diff --git a/modules/mandoline-mcp/outputs.tf b/modules/mandoline-mcp/outputs.tf index ad62a86..22de9d5 100644 --- a/modules/mandoline-mcp/outputs.tf +++ b/modules/mandoline-mcp/outputs.tf @@ -10,7 +10,7 @@ output "launcher_args" { output "launcher_path" { description = "Installed Mandoline binary path." - value = "/opt/mandoline" + value = "/opt/bin/mandoline" } output "mandoline_version" { diff --git a/modules/mandoline-mcp/scripts/install.sh.tpl b/modules/mandoline-mcp/scripts/install.sh.tpl index 69fd83d..3216470 100644 --- a/modules/mandoline-mcp/scripts/install.sh.tpl +++ b/modules/mandoline-mcp/scripts/install.sh.tpl @@ -3,8 +3,7 @@ set -eu umask 077 -state_dir="/tmp" -install_dir="/opt" +install_dir="/opt/bin" version_file="$install_dir/mandoline.version" selector='${mandoline_version}' asset='mandoline-linux-x86_64.tar.gz' @@ -36,7 +35,7 @@ fi mkdir -p "$install_dir" if [ ! -x "$install_dir/mandoline" ] || [ "$(cat "$version_file" 2>/dev/null || true)" != "$selector" ]; then - staging="$(mktemp -d "$state_dir/mandoline.XXXXXX")" + staging="$(mktemp -d)" trap 'rm -rf "$staging"' EXIT HUP INT TERM echo "Downloading Mandoline ($selector)…" curl --fail --silent --show-error --location --proto '=https' \ @@ -46,7 +45,8 @@ if [ ! -x "$install_dir/mandoline" ] || [ "$(cat "$version_file" 2>/dev/null || || fail 'Could not extract the Mandoline archive.' binary="$(find "$staging" -type f -name mandoline | head -n 1)" [ -n "$binary" ] || fail 'Mandoline archive did not contain a mandoline binary.' - install -m 0755 "$binary" "$install_dir/mandoline" + cp "$binary" "$install_dir/mandoline" + chmod 0755 "$install_dir/mandoline" printf '%s\n' "$selector" > "$version_file" chmod 600 "$version_file" rm -rf "$staging" From 5efcb74d29a1d5562dd6eb2e9a924aac4ad6dda0 Mon Sep 17 00:00:00 2001 From: Zach Yannes Date: Thu, 15 Oct 2026 14:32:05 -0400 Subject: [PATCH 5/7] fix(mandoline-mcp): download private releases with authenticated gh Use gh release list/download for semgrep/mandoline instead of a public HTTPS asset URL. Co-authored-by: Cursor --- modules/mandoline-mcp/README.md | 31 ++++++++------- modules/mandoline-mcp/main.tftest.hcl | 8 ++-- modules/mandoline-mcp/scripts/install.sh.tpl | 40 +++++++++++--------- 3 files changed, 44 insertions(+), 35 deletions(-) diff --git a/modules/mandoline-mcp/README.md b/modules/mandoline-mcp/README.md index afb6945..637bedd 100644 --- a/modules/mandoline-mcp/README.md +++ b/modules/mandoline-mcp/README.md @@ -2,7 +2,8 @@ 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 GitHub. +script downloads the Linux x86_64 release archive from the private Mandoline +GitHub repository using the authenticated GitHub CLI. ## Usage @@ -42,22 +43,24 @@ access to the installed Mandoline binary. ## Installation -The agent image must provide `curl`, `tar`, and a POSIX shell, and the agent -user must be able to write to `/opt/bin`. Initial installation needs outbound -HTTPS access to GitHub releases. The installer targets Linux x86_64 (`amd64`) -only: it creates a temporary directory, downloads and extracts -`mandoline-linux-x86_64.tar.gz`, installs the binary to `/opt/bin/mandoline`, -then removes the temporary directory. +The agent image must provide `gh`, `tar`, and a POSIX shell, and the agent user +must be able to write to `/opt/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`). -By default `mandoline_version = "latest"` downloads from -`https://github.com/semgrep/mandoline/releases/latest/download/mandoline-linux-x86_64.tar.gz`. -Pin an exact semantic version to install from the matching tagged release and -to reinstall when that pin changes. Changing the selector, or removing -`/opt/bin/mandoline` (and `/opt/bin/mandoline.version`), refreshes the binary -on the next workspace start. +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 `/opt/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 `/opt/bin/mandoline` (and `/opt/bin/mandoline.version`), +refreshes the binary on the next workspace start. Mandoline analyzes the client's working directory; no Coder external -authentication is required. +authentication is required for the MCP server itself. | Input | Default | Description | | --- | --- | --- | diff --git a/modules/mandoline-mcp/main.tftest.hcl b/modules/mandoline-mcp/main.tftest.hcl index f7586f3..eaac36a 100644 --- a/modules/mandoline-mcp/main.tftest.hcl +++ b/modules/mandoline-mcp/main.tftest.hcl @@ -22,8 +22,8 @@ run "defaults" { error_message = "The Mandoline install directory must be prepended to PATH." } assert { - condition = strcontains(coder_script.mandoline_mcp.script, "https://github.com/semgrep/mandoline/releases/latest/download/$asset") && strcontains(coder_script.mandoline_mcp.script, "asset='mandoline-linux-x86_64.tar.gz'") - error_message = "The default installer must download the latest Linux x86_64 release asset." + 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=\"/opt/bin\"") && strcontains(coder_script.mandoline_mcp.script, "mktemp -d") @@ -42,8 +42,8 @@ run "pinned_version" { error_message = "A pinned version must be recorded for idempotent upgrades." } assert { - condition = strcontains(coder_script.mandoline_mcp.script, "https://github.com/semgrep/mandoline/releases/download/v$${selector}/$asset") - error_message = "A pinned version must download from the matching GitHub release tag." + 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" diff --git a/modules/mandoline-mcp/scripts/install.sh.tpl b/modules/mandoline-mcp/scripts/install.sh.tpl index 3216470..8b4269f 100644 --- a/modules/mandoline-mcp/scripts/install.sh.tpl +++ b/modules/mandoline-mcp/scripts/install.sh.tpl @@ -1,5 +1,5 @@ #!/bin/sh -# Download Mandoline and place it on the agent PATH for `mandoline mcp`. +# Download Mandoline via gh and place it on the agent PATH for `mandoline mcp`. set -eu umask 077 @@ -7,13 +7,14 @@ install_dir="/opt/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 curl >/dev/null 2>&1 || fail 'curl is required to download Mandoline.' +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 @@ -27,31 +28,36 @@ case "$(uname -m)" in esac if [ "$selector" = latest ]; then - download_url="https://github.com/semgrep/mandoline/releases/latest/download/$asset" + 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 - download_url="https://github.com/semgrep/mandoline/releases/download/v$${selector}/$asset" + release="v$${selector}" fi mkdir -p "$install_dir" -if [ ! -x "$install_dir/mandoline" ] || [ "$(cat "$version_file" 2>/dev/null || true)" != "$selector" ]; then +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 ($selector)…" - curl --fail --silent --show-error --location --proto '=https' \ - "$download_url" -o "$staging/$asset" \ - || fail "Could not download Mandoline from $download_url." - tar -xzf "$staging/$asset" -C "$staging" \ - || fail 'Could not extract the Mandoline archive.' - binary="$(find "$staging" -type f -name mandoline | head -n 1)" - [ -n "$binary" ] || fail 'Mandoline archive did not contain a mandoline binary.' - cp "$binary" "$install_dir/mandoline" - chmod 0755 "$install_dir/mandoline" - printf '%s\n' "$selector" > "$version_file" + 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 ($selector); use \`mandoline mcp\`." +echo "Mandoline MCP installed ($release); use \`mandoline mcp\`." From 3f04e1b186467a7ef1fcab1ba8b6758151372709 Mon Sep 17 00:00:00 2001 From: Zach Yannes Date: Thu, 15 Oct 2026 14:36:29 -0400 Subject: [PATCH 6/7] style(mandoline-mcp): apply terraform fmt Co-authored-by: Cursor --- modules/mandoline-mcp/main.tftest.hcl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/mandoline-mcp/main.tftest.hcl b/modules/mandoline-mcp/main.tftest.hcl index eaac36a..29f243c 100644 --- a/modules/mandoline-mcp/main.tftest.hcl +++ b/modules/mandoline-mcp/main.tftest.hcl @@ -22,7 +22,7 @@ run "defaults" { 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'") + 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 { From f1ef51bf41c1686c954767f1205b1315e6e6db9d Mon Sep 17 00:00:00 2001 From: Zach Yannes Date: Thu, 15 Oct 2026 14:37:54 -0400 Subject: [PATCH 7/7] fix(mandoline-mcp): install Mandoline into /usr/local/bin Co-authored-by: Cursor --- modules/mandoline-mcp/README.md | 10 +++++----- modules/mandoline-mcp/main.tf | 2 +- modules/mandoline-mcp/main.tftest.hcl | 10 +++++----- modules/mandoline-mcp/outputs.tf | 2 +- modules/mandoline-mcp/scripts/install.sh.tpl | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/modules/mandoline-mcp/README.md b/modules/mandoline-mcp/README.md index 637bedd..7bcbe4b 100644 --- a/modules/mandoline-mcp/README.md +++ b/modules/mandoline-mcp/README.md @@ -16,7 +16,7 @@ module "mandoline_mcp" { } ``` -The module installs `mandoline` in `/opt/bin` and prepends that directory to +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: @@ -44,19 +44,19 @@ 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 `/opt/bin`. Because Mandoline releases are private, +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 `/opt/bin/mandoline`, and removes +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 `/opt/bin/mandoline` (and `/opt/bin/mandoline.version`), +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 @@ -68,7 +68,7 @@ authentication is required for the MCP server itself. | `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 `/opt/bin/mandoline`; `mandoline_version` echoes the +`launcher_path` is `/usr/local/bin/mandoline`; `mandoline_version` echoes the configured selector. ## Validation diff --git a/modules/mandoline-mcp/main.tf b/modules/mandoline-mcp/main.tf index 8cb6042..76fc29a 100644 --- a/modules/mandoline-mcp/main.tf +++ b/modules/mandoline-mcp/main.tf @@ -9,7 +9,7 @@ terraform { resource "coder_env" "mandoline_mcp_path" { agent_id = var.agent_id name = "PATH" - value = "/opt/bin:$PATH" + value = "/usr/local/bin:$PATH" merge_strategy = "prepend" } diff --git a/modules/mandoline-mcp/main.tftest.hcl b/modules/mandoline-mcp/main.tftest.hcl index 29f243c..965c7c5 100644 --- a/modules/mandoline-mcp/main.tftest.hcl +++ b/modules/mandoline-mcp/main.tftest.hcl @@ -14,11 +14,11 @@ run "defaults" { error_message = "Clients must receive mcp as the Mandoline MCP argument." } assert { - condition = output.launcher_path == "/opt/bin/mandoline" - error_message = "Clients must receive the /opt/bin Mandoline binary path." + 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 == "/opt/bin:$PATH" + 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 { @@ -26,8 +26,8 @@ run "defaults" { error_message = "The installer must download private Mandoline releases with an authenticated gh CLI." } assert { - condition = strcontains(coder_script.mandoline_mcp.script, "install_dir=\"/opt/bin\"") && strcontains(coder_script.mandoline_mcp.script, "mktemp -d") - error_message = "The installer must install into /opt/bin and stage downloads with mktemp -d." + 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." } } diff --git a/modules/mandoline-mcp/outputs.tf b/modules/mandoline-mcp/outputs.tf index 22de9d5..a2a90c6 100644 --- a/modules/mandoline-mcp/outputs.tf +++ b/modules/mandoline-mcp/outputs.tf @@ -10,7 +10,7 @@ output "launcher_args" { output "launcher_path" { description = "Installed Mandoline binary path." - value = "/opt/bin/mandoline" + value = "/usr/local/bin/mandoline" } output "mandoline_version" { diff --git a/modules/mandoline-mcp/scripts/install.sh.tpl b/modules/mandoline-mcp/scripts/install.sh.tpl index 8b4269f..40fd546 100644 --- a/modules/mandoline-mcp/scripts/install.sh.tpl +++ b/modules/mandoline-mcp/scripts/install.sh.tpl @@ -3,7 +3,7 @@ set -eu umask 077 -install_dir="/opt/bin" +install_dir="/usr/local/bin" version_file="$install_dir/mandoline.version" selector='${mandoline_version}' asset='mandoline-linux-x86_64.tar.gz'