Guide · MCP Policy Engine Integration
MCP Server Open Policy Agent — evaluate policies, query data, and validate Rego
Open Policy Agent (OPA) is the de facto standard for policy-as-code in cloud-native infrastructure — used in Kubernetes admission control, API gateways, microservice authorization, and CI/CD pipelines. This guide covers building TypeScript MCP tools that interact with OPA's REST API: evaluating policy rules with structured input, listing and retrieving Rego policy documents, updating policies with confirm guards, querying the OPA data layer for data-plane reads, using partial evaluation to generate pre-filters for database queries, and wiring a /health/opa endpoint that checks both process health and bundle sync status so AliveMCP catches degraded OPA deployments where the process is running but policy bundles are stale.
TL;DR
OPA's REST API has two distinct health endpoints: GET /health checks only that the OPA process is running (returns 200 even if bundles are not synced), while GET /health?bundles=true&plugins=true checks that all configured bundles are loaded and all plugins are healthy — this is the correct endpoint for monitoring. Policy evaluation uses POST /v1/data/:path — note the /v1/data/ prefix, not /v1/policies/. Policy documents (Rego source) use GET /v1/policies/:id. The result field in policy evaluation responses can be undefined if the rule is undefined for the given input — always check for undefined result before acting on the decision.
HTTP client setup
OPA's REST API is unauthenticated by default — you typically secure access via network policy or a sidecar proxy. For production deployments that expose OPA across service boundaries, configure OPA's built-in bearer token authentication or use mTLS.
import axios, { AxiosInstance } from 'axios';
const OPA_URL = process.env.OPA_URL ?? 'http://localhost:8181';
const OPA_TOKEN = process.env.OPA_TOKEN; // optional; only set if OPA is configured with authn
const opaHttp: AxiosInstance = axios.create({
baseURL: OPA_URL,
headers: {
'Content-Type': 'application/json',
...(OPA_TOKEN ? { 'Authorization': `Bearer ${OPA_TOKEN}` } : {})
},
timeout: 10_000 // OPA policy evaluation is typically sub-millisecond; 10s is generous
});
// OPA URL path structure:
// /v1/data/:path — evaluate a policy rule or read data (POST for evaluation with input)
// /v1/policies/:id — CRUD on Rego policy documents
// /v1/query — ad-hoc Rego queries
// /health — basic process health
// /health?bundles=true&plugins=true — full health including bundle sync
evaluate_policy tool
The POST /v1/data/:path endpoint evaluates a named rule in a Rego package and returns the rule's value given an input document. The path maps to a Rego package path — if your policy is package authz with a rule allow, the path is authz/allow. An undefined result (when no rules match) returns an empty result field, not a false value — always distinguish between false (explicitly denied) and undefined (no rule matched).
import { z } from 'zod';
import { McpError, ErrorCode } from '@modelcontextprotocol/sdk/types.js';
server.tool(
'evaluate_opa_policy',
{
policy_path: z.string().min(1), // e.g. 'authz/allow' maps to package authz rule allow
input: z.record(z.unknown()), // structured input document for the policy
explain: z.enum(['off', 'full', 'notes', 'fails']).default('off'),
instrument: z.boolean().default(false) // true returns timing/profiling data
},
async ({ policy_path, input, explain, instrument }) => {
try {
const params: Record = {};
if (explain !== 'off') params.explain = explain;
if (instrument) params.instrument = 'true';
const res = await opaHttp.post(`/v1/data/${policy_path}`, { input }, { params });
const data = res.data;
const resultDefined = 'result' in data;
return {
content: [{
type: 'text',
text: JSON.stringify({
policy_path,
decision: resultDefined ? data.result : null,
decision_defined: resultDefined,
// Undefined means no rule matched — caller should treat as "deny" if no default
note: resultDefined
? undefined
: 'Rule returned undefined — no policy rule matched this input. If your policy has no default allow/deny rule, undefined is the same as deny.',
...(data.explanation ? { explanation: data.explanation } : {}),
...(data.metrics ? { metrics: data.metrics } : {})
}, null, 2)
}]
};
} catch (err: any) {
if (err.response?.status === 404) {
throw new McpError(ErrorCode.InvalidParams,
`OPA policy path not found: ${policy_path}. Check that the package is loaded.`);
}
if (err.response?.data?.code === 'rego_type_error') {
throw new McpError(ErrorCode.InvalidParams,
`OPA type error: ${err.response.data.message}`);
}
throw err;
}
}
);
| OPA result value | Meaning | How to handle |
|---|---|---|
true |
Policy explicitly allows | Permit the action |
false |
Policy explicitly denies | Reject the action |
Object with allow: true + reason |
Rich decision with context | Use allow field; log reason |
undefined (no result key) |
No rule matched input | Treat as deny unless policy has a default |
list_policies and get_policy_document tools
server.tool(
'list_opa_policies',
{},
async () => {
const res = await opaHttp.get('/v1/policies');
const policies = (res.data.result ?? []).map((p: any) => ({
id: p.id,
rules: (p.ast?.rules ?? []).map((r: any) => r.head?.name).filter(Boolean),
package: p.ast?.package?.path?.map((v: any) => v.value).join('.') ?? 'unknown'
}));
return {
content: [{ type: 'text', text: JSON.stringify({ count: policies.length, policies }, null, 2) }]
};
}
);
server.tool(
'get_opa_policy',
{
policy_id: z.string().min(1) // the ID used when the policy was created via PUT /v1/policies/:id
},
async ({ policy_id }) => {
try {
const res = await opaHttp.get(`/v1/policies/${encodeURIComponent(policy_id)}`);
const policy = res.data.result;
return {
content: [{
type: 'text',
text: JSON.stringify({
id: policy.id,
raw_rego: policy.raw, // the Rego source code
package: policy.ast?.package?.path?.map((v: any) => v.value).join('.'),
rule_count: policy.ast?.rules?.length ?? 0
}, null, 2)
}]
};
} catch (err: any) {
if (err.response?.status === 404) {
throw new McpError(ErrorCode.InvalidParams, `OPA policy not found: ${policy_id}`);
}
throw err;
}
}
);
update_policy tool with confirm guard
Updating an OPA policy replaces the Rego source for an existing policy ID. OPA immediately compiles and activates the new Rego — incorrect Rego syntax causes a 400 error with a compiler error message. Always validate Rego with opa check or the Rego Playground before pushing to production.
server.tool(
'update_opa_policy',
{
policy_id: z.string().min(1),
rego_document: z.string().min(1), // full Rego source starting with 'package ...'
confirm: z.literal(true) // policy takes effect immediately — no canary
},
async ({ policy_id, rego_document }) => {
try {
// OPA PUT /v1/policies/:id expects the Rego source as text/plain body
const res = await opaHttp.put(
`/v1/policies/${encodeURIComponent(policy_id)}`,
rego_document,
{ headers: { 'Content-Type': 'text/plain' } }
);
return {
content: [{
type: 'text',
text: JSON.stringify({
policy_id,
updated: true,
metrics: res.data.metrics,
warning: 'Policy is now live. Verify evaluation behavior with evaluate_opa_policy.'
}, null, 2)
}]
};
} catch (err: any) {
if (err.response?.status === 400) {
throw new McpError(ErrorCode.InvalidParams,
`OPA compilation error: ${JSON.stringify(err.response.data.errors)}`);
}
throw err;
}
}
);
query_data and partial evaluation
OPA maintains a data layer independent of policies — you can push data documents (e.g., user roles, resource tags) via PUT /v1/data/:path and query them with GET /v1/data/:path. Partial evaluation (POST /v1/compile) is OPA's most powerful optimization: it takes a Rego query and an input with some unknowns, and returns a simplified policy that can be compiled into a database filter — enabling row-level security without fetching all rows.
server.tool(
'query_opa_data',
{
data_path: z.string(), // e.g. 'users/alice' or 'roles' — leave empty for full data doc
},
async ({ data_path }) => {
const path = data_path ? `/${data_path}` : '';
const res = await opaHttp.get(`/v1/data${path}`);
return {
content: [{ type: 'text', text: JSON.stringify(res.data.result ?? null, null, 2) }]
};
}
);
// Partial evaluation — generate pre-filters for database queries
server.tool(
'compile_opa_partial',
{
query: z.string().min(1), // Rego query, e.g. 'data.authz.allow == true'
input: z.record(z.unknown()), // input with known values
unknowns: z.array(z.string()).default(['input.resource']) // which refs OPA should treat as unknown
},
async ({ query, input, unknowns }) => {
const res = await opaHttp.post('/v1/compile', {
query,
input,
unknowns
});
// Returns a partial policy — a set of conditions on the unknowns
// that, if true, guarantee the original query evaluates to true
return {
content: [{
type: 'text',
text: JSON.stringify({
partial_policies: res.data.result?.queries ?? [],
explanation: 'These conditions on the unknowns guarantee the policy allows access. Translate to SQL WHERE clauses or equivalent for row-level security.'
}, null, 2)
}]
};
}
);
Health endpoint: /health/opa
OPA's /health endpoint has an important distinction: with no query parameters, it only verifies the OPA process is alive. With ?bundles=true&plugins=true, it verifies that all configured bundles have been successfully downloaded and that all plugins have reported healthy status. For AliveMCP monitoring, always use the full health check — a degraded OPA where bundles failed to load will silently evaluate policies against stale or empty data.
import express from 'express';
const app = express();
app.get('/health/opa', async (_req, res) => {
const start = Date.now();
try {
// Use the full health check — not just /health
const opaRes = await opaHttp.get('/health', {
params: { bundles: 'true', plugins: 'true' },
validateStatus: () => true // handle non-200 ourselves
});
const healthy = opaRes.status === 200;
res.status(healthy ? 200 : 503).json({
status: healthy ? 'ok' : 'degraded',
opa_status: opaRes.status,
opa_body: opaRes.data, // contains per-bundle and per-plugin status on failure
note: healthy
? 'OPA process running and all bundles synced'
: 'OPA is running but bundles or plugins are not healthy — policy data may be stale',
latency_ms: Date.now() - start
});
} catch (err: any) {
res.status(503).json({
status: 'error',
error: err.message,
note: 'OPA process is not reachable',
latency_ms: Date.now() - start
});
}
});
app.listen(3001);
| OPA health endpoint | What it checks | Recommendation |
|---|---|---|
GET /health |
Process alive only | Not sufficient for monitoring |
GET /health?bundles=true |
Process + bundle sync | Use if you load policy via bundles |
GET /health?plugins=true |
Process + plugin health | Use if you have external plugins |
GET /health?bundles=true&plugins=true |
Full health | Recommended for AliveMCP monitoring |
Frequently asked questions
When should I use OPA's data layer versus injecting data into input?
Use the input document for request-time data that changes with every authorization decision: the requesting user, the resource being accessed, the action, and HTTP request attributes. Use OPA's data layer for reference data that changes infrequently: role definitions, resource tags, organization hierarchy, and group memberships. Pushing reference data to OPA's data layer via bundle or push API lets OPA join policy rules with data without external calls during evaluation — keeping decision latency sub-millisecond. If you're passing large data structures in input on every request, that's a sign the data belongs in the data layer instead.
What's the difference between OPA bundles and the push API?
OPA bundles are archives (tar.gz) containing Rego files and data.json files, served from an HTTP endpoint (like an S3 bucket or dedicated bundle server). OPA polls the bundle endpoint on a configurable interval and applies updates atomically. Push API (PUT /v1/data/:path, PUT /v1/policies/:id) allows individual updates without a bundle server. Use bundles for production deployments — they provide atomic updates, rollback on failure, and are the native pattern for OPA in GitOps workflows. Use the push API for development, testing, or scenarios where you need to update a single rule or data document without a full bundle rebuild.
How do I debug why a policy returned undefined instead of false?
Add ?explain=full to the evaluation request — OPA returns a full trace of every rule evaluation, including which rules were evaluated and why each one succeeded or failed to match. Alternatively, use ?explain=fails to get only the failing branches. The most common cause of undefined results is missing a default allow = false statement in your Rego package — without it, rules that don't match return undefined rather than false. For interactive debugging, use opa eval with the --explain full flag on the command line.
How do I implement row-level security using partial evaluation?
Write your authorization policy with an unknown reference to the resource being accessed. For example: allow { input.user.roles[_] == data.resources[input.resource.id].required_role }. When you call /v1/compile with input.resource as unknown, OPA returns the conditions on resources that would satisfy the policy for the given user. You then translate those conditions into SQL WHERE clauses before running the database query — the database enforces the filter, and you never fetch rows the user can't access. This pattern scales to millions of rows without performance degradation.
Further reading
- MCP Server Auth0 — M2M tokens, user management, and role assignment
- MCP Server Cloudflare Access — JWT validation and access policy management
- MCP Server AWS IAM — assume roles and simulate policies
- MCP Server Health Check — wiring /health endpoints for uptime monitoring
- MCP Server Error Handling — mapping HTTP errors to McpError codes