diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..0f76c65 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + go-deps: + patterns: ["*"] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + groups: + actions: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7112ba6..a8cb947 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,20 +12,68 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: go.mod - run: go vet ./... - - run: go test -race -count=1 -v ./... + - name: Run tests with coverage + run: go test -race -count=1 -covermode=atomic -coverprofile=coverage.out -v ./... + - name: Coverage summary + run: go tool cover -func=coverage.out | tail -n 1 lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9.2.1 + with: + version: v2.12.2 + + smoke: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: go.mod - - uses: golangci/golangci-lint-action@v6 + - name: Run end-to-end MCP smoke test + run: make smoke + + vuln: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 + with: + go-version-file: go.mod + - name: govulncheck (Go module CVE scan) + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... + - name: osv-scanner (OSV.dev cross-ecosystem scan) + uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + scan-args: |- + --recursive + ./ + + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: - version: latest + persist-credentials: false + - name: Lint GitHub Actions workflows + uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 81ab5a6..b90150f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,22 +14,23 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 with: fetch-depth: 0 + persist-credentials: false - - uses: actions/setup-go@v6 + - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0 with: go-version-file: go.mod - name: Install syft - uses: anchore/sbom-action/download-syft@v0 + uses: anchore/sbom-action/download-syft@f8bdd1d8ac5e901a77a92f111440fdb1b593736b # v0.20.6 - name: Install cosign - uses: sigstore/cosign-installer@v3 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7.0.0 with: distribution: goreleaser version: latest diff --git a/.goreleaser.yml b/.goreleaser.yml index 4b607ec..70c87b9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -4,7 +4,9 @@ project_name: mcp-opa before: hooks: - - go mod tidy + # `verify` checks checksums without mutating go.mod / go.sum, so a + # release stays bit-reproducible. CI / dev should run `go mod tidy`. + - go mod verify builds: - id: mcp-opa diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6872d99 --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +BIN := mcp-opa +PKG := ./... + +.PHONY: build test vet lint smoke clean fmt tidy + +build: + go build -trimpath -ldflags "-s -w" -o $(BIN) . + +test: + go test -race -count=1 -v $(PKG) + +vet: + go vet $(PKG) + +lint: + golangci-lint run $(PKG) + +smoke: build + ./scripts/smoke.sh ./$(BIN) + +fmt: + gofmt -s -w . + +tidy: + go mod tidy + +clean: + rm -f $(BIN) + rm -rf dist/ diff --git a/README.md b/README.md index 40e9d98..0b35f47 100644 --- a/README.md +++ b/README.md @@ -2,25 +2,11 @@ [![ci](https://github.com/0-draft/mcp-opa/actions/workflows/ci.yml/badge.svg)](https://github.com/0-draft/mcp-opa/actions/workflows/ci.yml) [![Go Reference](https://pkg.go.dev/badge/github.com/0-draft/mcp-opa.svg)](https://pkg.go.dev/github.com/0-draft/mcp-opa) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) -A [Model Context Protocol](https://modelcontextprotocol.io) server that lets an LLM agent run [Open Policy Agent](https://www.openpolicyagent.org/) Rego evaluations as a tool. +[MCP](https://modelcontextprotocol.io) server that exposes [OPA](https://www.openpolicyagent.org/) Rego evaluation as a tool an LLM agent can call. -Useful for "let Claude reason about a policy" workflows: paste a Rego module + an input doc into a chat and ask the model to walk through what happens. The model writes the query; `mcp-opa` runs OPA and returns the decision set. - -## Tool - -### `evaluate_policy` - -Evaluate a Rego module against an input document and optional `data` namespace. - -| Parameter | Required | Description | -| ------------ | -------- | ---------------------------------------------------------------------------------------- | -| `rego` | yes | Rego source code. Must include a `package` declaration. | -| `query` | yes | Query to evaluate, e.g. `data.example.allow` or `data.example.violations[_]`. | -| `input_json` | no | JSON-encoded input document (becomes the `input` variable inside Rego). | -| `data_json` | no | JSON-encoded base document seeding the `data` namespace via OPA's in-memory store. | - -The tool returns the OPA `ResultSet` as JSON. +Paste a Rego module and an input doc into a Claude / Cursor session. The model picks the query, `mcp-opa` runs `opa.Eval`, returns the decision set. ## Install @@ -28,43 +14,78 @@ The tool returns the OPA `ResultSet` as JSON. go install github.com/0-draft/mcp-opa@latest ``` -Or grab a signed binary from the [releases page](https://github.com/0-draft/mcp-opa/releases). +Pre-built signed binaries are on the [releases page](https://github.com/0-draft/mcp-opa/releases). -## Use with Claude Code +## Quickstart ```bash -claude mcp add opa -- mcp-opa +# Build and run the smoke test (no MCP client needed). +make smoke +# → ✓ smoke: allow=true ``` -Then in a Claude Code session: +`make smoke` builds the binary, feeds it a synthetic MCP `initialize` → +`tools/list` → `tools/call` sequence over stdio, and asserts the policy +returned `allow=true`. It exits non-zero on protocol failure. -> Evaluate this RBAC policy against the request — does Alice get to delete the document? -> -> ```rego -> package rbac -> default allow := false -> allow if input.user == "alice" and input.action == "read" -> ``` +## Wire it to Claude Code -Claude calls `evaluate_policy` with the right query, returns the decision. +```bash +claude mcp add opa -- mcp-opa +``` -## Use with Cursor / other MCP clients +Then in a session: paste a Rego policy, an input doc, and ask the model what +the decision should be. The model calls `evaluate_policy`, gets the answer +from OPA (not from its training data). -Add to your client's MCP server config: +## Wire it to Cursor / other clients -```json +```jsonc { "mcpServers": { - "opa": { - "command": "mcp-opa" - } + "opa": { "command": "mcp-opa" } } } ``` -## Verifying a release +## Tool: `evaluate_policy` + +| Param | Required | Description | +| ------------ | -------- | -------------------------------------------------------------------------- | +| `rego` | yes | Rego source with a `package` declaration. | +| `query` | yes | Rego query, e.g. `data.example.allow`. | +| `input_json` | no | JSON-encoded `input` document. | +| `data_json` | no | JSON-encoded base document for the `data` namespace. | + +Returns the OPA `ResultSet` as JSON. + +## Examples + +[`examples/`](./examples/) has reference policies: + +- [`rbac.rego`](./examples/rbac.rego) — role → permission mapping +- [`abac.rego`](./examples/abac.rego) — clearance level comparison +- [`k8s_admission.rego`](./examples/k8s_admission.rego) — admission control: required labels + +## Layout + +Flat on purpose. A single-binary MCP server with one tool does not need +`cmd/`, `internal/`, or `pkg/`. When a second tool joins, the natural split is +a sibling file (`evaluate.go`, `lint.go`, ...) — still no subpackages. + +```text +. +├── main.go # server bootstrap + tool registration +├── main_test.go +├── examples/ # reference Rego policies +├── scripts/smoke.sh +├── .goreleaser.yml +└── .github/ +``` + +## Verify a release -Each release ships a `cosign`-signed checksum file (keyless, Sigstore via GitHub OIDC) and a CycloneDX SBOM. To verify before installing: +Releases ship a `cosign`-signed checksum file (Sigstore keyless via GitHub OIDC) and a CycloneDX SBOM per archive. ```bash TAG=v0.1.0 diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..de58695 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,30 @@ +# Examples + +Reference Rego policies you can paste into `evaluate_policy` to see how `mcp-opa` behaves. + +| File | Pattern | +| ------------------------------------------ | --------------------------------------------- | +| [`rbac.rego`](./rbac.rego) | Role-based: roles → permissions | +| [`abac.rego`](./abac.rego) | Attribute-based: subject + resource matching | +| [`k8s_admission.rego`](./k8s_admission.rego) | Kubernetes admission control (require labels) | + +## Running an example end-to-end + +```bash +# 1. Start mcp-opa from any MCP client config (Claude Code shown): +claude mcp add opa -- mcp-opa + +# 2. In a session, ask: +# "Evaluate examples/rbac.rego for alice trying to delete document doc-1. +# What's the query?" +# +# Claude will read the rego, pick `data.rbac.allow`, send input +# {"user": "alice", "action": "delete", "resource": "doc-1"}, +# and read back the decision. +``` + +You can also drive it manually over stdio (advanced; MCP is JSON-RPC over stdin/stdout): + +```bash +echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | mcp-opa +``` diff --git a/examples/abac.rego b/examples/abac.rego new file mode 100644 index 0000000..bd493ce --- /dev/null +++ b/examples/abac.rego @@ -0,0 +1,19 @@ +package abac + +# Attribute-based access control: subject + resource attributes must align. +# +# Query: data.abac.allow +# Input: { +# "subject": {"id": "alice", "clearance": "secret"}, +# "resource": {"id": "report-99", "classification": "secret"}, +# "action": "read" +# } + +default allow := false + +levels := {"public": 0, "internal": 1, "confidential": 2, "secret": 3} + +allow if { + input.action == "read" + levels[input.subject.clearance] >= levels[input.resource.classification] +} diff --git a/examples/k8s_admission.rego b/examples/k8s_admission.rego new file mode 100644 index 0000000..e1ff799 --- /dev/null +++ b/examples/k8s_admission.rego @@ -0,0 +1,17 @@ +package k8s.admission + +# Kubernetes admission control: every Pod must carry an `app` label. +# +# Query: data.k8s.admission.violations[_] +# Input: the AdmissionReview request (or just the Pod object's metadata) + +violations contains msg if { + input.kind.kind == "Pod" + not input.object.metadata.labels.app + # Use object.get with fallbacks so an undefined name/namespace doesn't make + # the whole sprintf evaluate to undefined and silently swallow the + # violation. + namespace := object.get(input.object.metadata, "namespace", "default") + name := object.get(input.object.metadata, "name", "") + msg := sprintf("Pod %s/%s is missing required label `app`", [namespace, name]) +} diff --git a/examples/rbac.rego b/examples/rbac.rego new file mode 100644 index 0000000..04c5b28 --- /dev/null +++ b/examples/rbac.rego @@ -0,0 +1,26 @@ +package rbac + +# Role-based access control. +# +# Query: data.rbac.allow +# Input: {"user": "alice", "action": "read", "resource": "doc-1"} + +default allow := false + +roles := { + "alice": {"editor"}, + "bob": {"viewer"}, + "carol": {"viewer", "auditor"}, +} + +permissions := { + "editor": {"read", "write", "delete"}, + "viewer": {"read"}, + "auditor": {"read", "audit"}, +} + +allow if { + some role in roles[input.user] + some perm in permissions[role] + perm == input.action +} diff --git a/go.mod b/go.mod index f69a232..0c47dac 100644 --- a/go.mod +++ b/go.mod @@ -36,9 +36,9 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.50.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index b50a7c7..e23d2e6 100644 --- a/go.sum +++ b/go.sum @@ -130,20 +130,20 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/scripts/smoke.sh b/scripts/smoke.sh new file mode 100755 index 0000000..d612a0e --- /dev/null +++ b/scripts/smoke.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Drive mcp-opa over stdio with a full MCP handshake and one tools/call. +# Useful before tagging a release, or after upgrading mcp-go. +# +# Exit codes: +# 0 decision was true (policy allowed) +# 1 decision was false (policy denied or no allow rule matched) +# 2 protocol failure (no result, no decision in payload) + +set -euo pipefail + +BIN="${1:-./mcp-opa}" +if [[ ! -x "$BIN" ]]; then + echo "build first: go build ." >&2 + exit 2 +fi + +read -r -d '' REGO <<'EOF' || true +package smoke + +default allow := false + +allow if input.user == "alice" +EOF + +OUT=$(printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + "$(jq -nc --arg r "$REGO" '{jsonrpc:"2.0",id:2,method:"tools/call",params:{name:"evaluate_policy",arguments:{rego:$r,query:"data.smoke.allow",input_json:"{\"user\":\"alice\"}"}}}')" \ + | "$BIN") + +DECISION=$(printf '%s\n' "$OUT" | jq -r 'select(.id == 2) | .result.content[0].text' | jq -r '.[0].expressions[0].value') + +case "$DECISION" in + true) echo "✓ smoke: allow=true"; exit 0 ;; + false) echo "✗ smoke: allow=false (expected true)"; exit 1 ;; + *) echo "✗ smoke: protocol failure; payload:"; printf '%s\n' "$OUT"; exit 2 ;; +esac