Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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: ["*"]
29 changes: 29 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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/
95 changes: 58 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,69 +2,90 @@

[![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

```bash
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
Expand Down
30 changes: 30 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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 with `curl` over stdio (advanced — MCP is JSON-RPC over stdin/stdout):
Comment thread
kanywst marked this conversation as resolved.
Outdated

```bash
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | mcp-opa
```
19 changes: 19 additions & 0 deletions examples/abac.rego
Original file line number Diff line number Diff line change
@@ -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]
}
15 changes: 15 additions & 0 deletions examples/k8s_admission.rego
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
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
msg := sprintf("Pod %s/%s is missing required label `app`", [
input.object.metadata.namespace,
input.object.metadata.name,
])
}
Comment thread
kanywst marked this conversation as resolved.
26 changes: 26 additions & 0 deletions examples/rbac.rego
Original file line number Diff line number Diff line change
@@ -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
}
38 changes: 38 additions & 0 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
@@ -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" | tail -1 | jq -r '.result.content[0].text' | jq -r '.[0].expressions[0].value')
Comment thread
kanywst marked this conversation as resolved.
Outdated

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
Loading