Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/mandoline-mcp.yaml
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```
82 changes: 82 additions & 0 deletions modules/mandoline-mcp/README.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions modules/mandoline-mcp/main.tf
Original file line number Diff line number Diff line change
@@ -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
})
}
61 changes: 61 additions & 0 deletions modules/mandoline-mcp/main.tftest.hcl
Original file line number Diff line number Diff line change
@@ -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]
}
19 changes: 19 additions & 0 deletions modules/mandoline-mcp/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
63 changes: 63 additions & 0 deletions modules/mandoline-mcp/scripts/install.sh.tpl
Original file line number Diff line number Diff line change
@@ -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\`."
15 changes: 15 additions & 0 deletions modules/mandoline-mcp/variables.tf
Original file line number Diff line number Diff line change
@@ -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."
}
}
Loading