{
  "markdown": "# mcp-opa-authz\n\n[![ci](https://github.com/kanywst/mcp-opa-authz/actions/workflows/ci.yml/badge.svg)](https://github.com/kanywst/mcp-opa-authz/actions/workflows/ci.yml)\n[![Go Reference](https://pkg.go.dev/badge/github.com/kanywst/mcp-opa-authz.svg)](https://pkg.go.dev/github.com/kanywst/mcp-opa-authz)\n[![Go Report Card](https://goreportcard.com/badge/github.com/kanywst/mcp-opa-authz)](https://goreportcard.com/report/github.com/kanywst/mcp-opa-authz)\n[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)\n\n**Stop letting your agent guess at authorization.** This is an [MCP](https://modelcontextprotocol.io) server that answers \"is this allowed?\" from real policy code — either Rego you hand it, or the [OpenID AuthZEN 1.0](https://openid.net/specs/authorization-api-1_0.html) PDP that actually governs your system.\n\nAsk a model whether Alice may delete that document and it will produce a confident, plausible, unfalsifiable answer. Give it these tools and the answer comes from the policy.\n\n```bash\ngo install github.com/kanywst/mcp-opa-authz@latest\nclaude mcp add opa-authz -- mcp-opa-authz\n```\n\nThat is enough for `evaluate_policy`. Point it at a PDP to get the rest:\n\n```bash\nclaude mcp add opa-authz \\\n  --env AUTHZEN_PDP_URL=http://localhost:8181/access/v1/evaluation \\\n  -- mcp-opa-authz\n```\n\n## Two layers, same question\n\n| Tool | Answers | Needs |\n| --- | --- | --- |\n| `evaluate_policy` | \"What does this Rego say?\" Evaluated in-process by [OPA](https://www.openpolicyagent.org/). | Nothing external |\n| `authzen_evaluate` | \"What does the PDP that governs this system say?\" | A reachable PDP |\n| `authzen_evaluate_batch` | The same, over a list — which of these may the subject touch? | A reachable PDP |\n| `authzen_discover` | \"Which endpoints does this PDP offer?\" | A reachable PDP |\n\nUse `evaluate_policy` while authoring or debugging a policy you have the source of. Use `authzen_evaluate` when the decision has to come from production, not from a policy pasted into the chat. The server's MCP instructions tell the model the same thing, so it usually picks correctly on its own.\n\n## What makes this different from `opa eval` in a shell tool\n\nAn agent given shell access can already run `opa eval`. What it cannot do is get an answer it is allowed to trust:\n\n- **Undefined is not false.** A Rego query with no matching rule and no default returns `[]`. Every model reads that as a deny. `evaluate_policy` returns `defined` alongside `value`, so \"the policy denied\" and \"the policy has no opinion\" stop being the same answer.\n- **A PDP that did not answer is not a deny.** AuthZEN makes `decision` a required member. A response missing it decodes into a Go `bool` as `false` — a broken PDP would look like a strict one. This server treats a missing `decision` as a failure, never as a deny. Same for a 401: that means *this server* failed to authenticate, not that the subject was denied, and the error says so.\n- **The policy runs in a sandbox.** Rego handed to an MCP server was written by a model, from text that may have come from a web page. OPA's `http.send` would let that policy make arbitrary HTTP requests from your laptop, and `opa.runtime()` would hand it your environment. Both are compiled out. See [Security](#security).\n- **Batch decisions carry their index.** `permit_on_first_permit` legitimately returns fewer decisions than you sent. Zipping the arrays would attach a decision to the wrong resource.\n\n## Demo\n\nDebugging a policy that is denying when it should not:\n\n```text\n> Why is bob getting denied on doc-1? Here's the policy and the input.\n\n  evaluate_policy(rego=…, query=\"data.rbac.allow\", input_json=…, trace=true)\n\n  {\n    \"defined\": true,\n    \"value\": false,\n    \"printed\": [\"checking roles for\", \"bob\"],\n    \"trace\": [\n      \"Enter data.rbac.allow\",\n      \"| Eval some role in roles[input.user]\",\n      \"| Fail roles[\\\"bob\\\"]\",\n      …\n    ]\n  }\n\nBob has no entry in `roles` at all — the rule never reaches the permission\ncheck. Adding \"bob\": {\"viewer\"} fixes it.\n```\n\nThen confirming against the PDP that actually runs:\n\n```text\n> Does production agree?\n\n  authzen_evaluate(subject={\"type\":\"user\",\"id\":\"bob\"}, …)\n\n  { \"decision\": false, \"context\": { \"reason\": \"no role binding\" },\n    \"request_id\": \"3f9c…\", \"pdp_url\": \"https://pdp.internal/access/v1/evaluation\" }\n```\n\n## Tools\n\n### `evaluate_policy`\n\n| Param | Required | Description |\n| --- | --- | --- |\n| `rego` | yes | Rego source with a `package` declaration. |\n| `query` | yes | Rego query, e.g. `data.example.allow`. |\n| `input_json` | no | JSON-encoded `input` document. |\n| `data_json` | no | JSON-encoded base document for the `data` namespace. |\n| `rego_version` | no | `v1` (default) or `v0` for pre-OPA-1.0 syntax. |\n| `trace` | no | Return a pretty-printed evaluation trace. Verbose; bounded at 4000 events, 200 lines, 1 KiB per line. |\n\nReturns `defined`, `value`, the raw OPA `result_set` (omitted with `result_set_omitted` past 256 KiB encoded), any `print()` output (200 lines of 1 KiB), and the trace when asked for.\n\n### `authzen_evaluate`\n\n| Param | Required | Description |\n| --- | --- | --- |\n| `subject` | yes | JSON object. AuthZEN requires `type` and `id`. |\n| `action` | yes | JSON object. AuthZEN requires `name`. |\n| `resource` | yes | JSON object. AuthZEN requires `type` and `id`. |\n| `context` | no | JSON object with runtime context (IP, time, MFA strength). |\n| `pdp_url` | no | Override `AUTHZEN_PDP_URL` for this call. |\n\nReturns `decision`, the PDP's `context` if any, the `pdp_url` that answered, and the `request_id` sent as `X-Request-ID` — so a decision in a transcript can be found in the PDP's logs.\n\n### `authzen_evaluate_batch`\n\nSame arguments, plus `evaluations` (a JSON array whose entries override the top-level defaults) and `evaluations_semantic` (`execute_all`, `deny_on_first_deny`, `permit_on_first_permit`). Capped at 100 entries per call.\n\n```json\n{\n  \"subject\": \"{\\\"type\\\":\\\"user\\\",\\\"id\\\":\\\"alice\\\"}\",\n  \"action\": \"{\\\"name\\\":\\\"read\\\"}\",\n  \"evaluations\": \"[{\\\"resource\\\":{\\\"type\\\":\\\"doc\\\",\\\"id\\\":\\\"1\\\"}},{\\\"resource\\\":{\\\"type\\\":\\\"doc\\\",\\\"id\\\":\\\"2\\\"}}]\"\n}\n```\n\n### `authzen_discover`\n\nFetches `/.well-known/authzen-configuration` from a PDP root. `pdp_url` may be a root or an evaluation endpoint — the known AuthZEN path suffix is stripped, and a PDP mounted under a prefix keeps its prefix.\n\n## Standards conformance\n\nImplements [Authorization API 1.0](https://openid.net/specs/authorization-api-1_0.html), approved as an OpenID **Final Specification** in January 2026:\n\n| Section | Status |\n| --- | --- |\n| Access Evaluation (`POST /access/v1/evaluation`) | `authzen_evaluate` |\n| Access Evaluations, batch (`POST /access/v1/evaluations`) | `authzen_evaluate_batch` |\n| PDP Metadata (`GET /.well-known/authzen-configuration`) | `authzen_discover` |\n| Subject / Action / Resource information model | Required members validated before the request is sent |\n| `X-Request-ID` correlation | Sent on every call, returned in the result |\n| Search APIs (subject / resource / action) | Not implemented — [open an issue](https://github.com/kanywst/mcp-opa-authz/issues) if you need them |\n\nRelated work worth knowing about: the AuthZEN working group's [COAZ profile](https://github.com/openid/authzen/blob/main/profiles/authzen-coaz-mcp-binding-1_0.md) binds AuthZEN to MCP tool calls themselves, so a gateway can authorize `tools/call` with an `x-authzen-mapping` declared in a tool's `inputSchema`. That is the enforcement side of the same problem — this server is the *inspection* side, and the two compose.\n\n## Configuration\n\n| Variable | Default | Description |\n| --- | --- | --- |\n| `AUTHZEN_PDP_URL` | — | Default Access Evaluation endpoint. |\n| `AUTHZEN_PDP_TOKEN` | — | `Authorization` header value. A value with no scheme is sent as `Bearer <token>`. |\n| `AUTHZEN_PDP_TIMEOUT` | `10s` | Per-request timeout. |\n| `AUTHZEN_PDP_MAX_RESPONSE_BYTES` | `1048576` | Response read limit. |\n| `MCP_OPA_EVAL_TIMEOUT` | `5s` | Wall-clock limit on one Rego evaluation. |\n| `MCP_MAX_ARG_BYTES` | `1048576` | Per-argument size limit. |\n| `MCP_OPA_ALLOW_NETWORK_BUILTINS` | `false` | Re-enable the network built-ins. See below. |\n\nA malformed value stops the server at startup rather than being silently replaced by the default — a bound an operator believes is in place should be in place.\n\n## Security\n\n`evaluate_policy` compiles and runs Rego that a model produced, inside this process. Three OPA built-ins are removed from the capability set for that reason:\n\n| Built-in | Why |\n| --- | --- |\n| `http.send` | Arbitrary HTTP from wherever the server runs — a laptop or CI runner, inside whatever network boundary that sits behind. It is the whole SSRF surface, and it contradicts the tool's own description. |\n| `net.lookup_ip_addr` | Enough to exfiltrate `input` one DNS label at a time, with no port reachable. |\n| `opa.runtime` | Returns the runtime configuration, including the process environment and every credential in it. |\n\nTime, JWT, UUID and random built-ins are untouched — those appear in real authorization policies. A policy using a removed built-in fails to compile with a message naming it, and pointing at `MCP_OPA_ALLOW_NETWORK_BUILTINS` for the cases where you genuinely want it.\n\nThe PDP client is constrained too: `pdp_url` must be an absolute `http(s)` URL with a host and no userinfo, redirects are refused rather than followed with the `Authorization` header attached, responses are read through a byte cap, and PDP error bodies are truncated before they reach the model's context.\n\nReporting a vulnerability: see [SECURITY.md](./SECURITY.md).\n\n## Running it\n\n### From source\n\n```bash\nmake smoke\n```\n\nBuilds the binary, stands up a fake AuthZEN PDP, drives one real MCP stdio session through all four tools, and asserts each answered — including that `http.send` is still rejected. No MCP client and no real PDP needed.\n\n### Container\n\n```bash\ndocker run -i --rm \\\n  -e AUTHZEN_PDP_URL=https://pdp.example.com/access/v1/evaluation \\\n  ghcr.io/kanywst/mcp-opa-authz\n```\n\n### Cursor and other MCP clients\n\n```jsonc\n{\n  \"mcpServers\": {\n    \"opa-authz\": {\n      \"command\": \"mcp-opa-authz\",\n      \"env\": { \"AUTHZEN_PDP_URL\": \"http://localhost:8181/access/v1/evaluation\" }\n    }\n  }\n}\n```\n\nA local [opa-authzen-plugin](https://github.com/kanywst/opa-authzen-plugin) on `:8181` works as the PDP. So does [Topaz](https://www.topaz.sh/), [Cerbos](https://www.cerbos.dev/), [Keycloak](https://www.keycloak.org/2026/05/authzen-as-experimental-feature) with its experimental AuthZEN support, or anything else on the [interop list](https://authzen-interop.net/).\n\n## Examples\n\n[`examples/`](./examples/) has reference Rego policies for `evaluate_policy`:\n\n- [`rbac.rego`](./examples/rbac.rego) — role to permission mapping\n- [`abac.rego`](./examples/abac.rego) — clearance level comparison\n- [`k8s_admission.rego`](./examples/k8s_admission.rego) — admission control: required labels\n\n## Layout\n\nFlat on purpose. A single-binary MCP server does not need `cmd/`, `internal/` or `pkg/`.\n\n```text\nmain.go               server bootstrap, CLI, tool registration\nconfig.go             environment, bounds, defaults\nargs.go               tool argument decoding\ntool_opa.go           evaluate_policy\nopa_capabilities.go   the Rego sandbox\nauthzen.go            AuthZEN 1.0 wire types and PDP client\ntool_authzen.go       authzen_evaluate, _batch, _discover\nscripts/smoke.sh      end-to-end MCP session\n```\n\n## Verifying a release\n\nReleases ship a `cosign`-signed checksum file (Sigstore keyless via GitHub OIDC) and a CycloneDX SBOM per archive. The signature and its certificate travel together in one Sigstore bundle, `*-checksums.txt.sigstore.json`.\n\n```bash\nTAG=v0.1.0\ngh release download \"$TAG\" -R kanywst/mcp-opa-authz -p '*-checksums.txt*'\n\ncosign verify-blob \\\n  --bundle \"mcp-opa-authz-${TAG#v}-checksums.txt.sigstore.json\" \\\n  --certificate-identity-regexp 'https://github.com/kanywst/mcp-opa-authz/.github/workflows/release.yml@refs/tags/' \\\n  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \\\n  \"mcp-opa-authz-${TAG#v}-checksums.txt\"\n```\n\nThen check the archive you downloaded against that file:\n\n```bash\nsha256sum -c \"mcp-opa-authz-${TAG#v}-checksums.txt\" --ignore-missing\n```\n\n## Contributing\n\nIssues and pull requests are welcome — see [CONTRIBUTING.md](./CONTRIBUTING.md). Good first contributions: another example policy, a PDP this has not been tried against, or a gap against the AuthZEN text.\n\nThis repo is the merge of the former `0-draft/mcp-opa` and `0-draft/mcp-authzen`. Both histories are preserved here.\n\n## License\n\nMIT. See [LICENSE](./LICENSE).\n",
  "bytes": 12678,
  "sha": "df10950d7a565733d9c18a96396cd7fdc5e1392e5605dc71cb317e9e4860bc9d",
  "repo_slug": "kanywst/mcp-opa-authz",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_kanywst_mcp_opa_authz_aa59cbea/readme"
}