Put OPA in Front of Your Quarkus MCP Tools
Use structured policy input and caller context to decide which MCP tools a client can see and call.
opa_decision={"allow":false,"skillId":"pptx_export","reasonCodes":["SCOPE_NOT_ALLOWED"]}
That log line records an enforced decision. The filesystem restriction lives in policy. It does not depends on a model hopefully following an instruction.
Prompt guardrails inspect language before or after a model call. The recent Quarkus OPA guardrail article shows how to compile a Rego policy to WebAssembly and evaluate it inside a Quarkus LangChain4j application. Its policy reads prompt text, including regular expressions. To be completely honest, I am not a big fan of prompt inspection with RegEx. It just does not scale. And is just a weak and potentially harmful approach which will never solve the challenges you will experience in guardrails. I’ve written about this before.
But there are great use-cases for this. In this example, OPA reads facts that the application already knows: the user, agent, session, skill publisher, signature status, and requested scopes.
I use Fernbank as a fictional internal agent platform in this example. Teams publish document generators, database helpers, and deployment skills into a shared catalog. A scanner can flag a suspicious manifest in CI, but the report does not block runtime access. Fernbank needs a gate at the point where it exposes the tool.
We build the gate as a Quarkus MCP server. OPA evaluates every tool against its manifest and the caller context. Allowed tools appear in tools/list and can be called. Denied tools stay out of the list, and a direct tools/call also fails. Each evaluation writes the policy version and stable reason codes to a fixed-size audit buffer.
The Quarkus Enforcement Boundary
We need two related controls. They run at different times and solve different problems.
Catalog admission decides whether a skill may enter the shared catalog. It runs once per skill version and uses publisher data, signature status, source information, and declared capabilities.
Per-client exposure decides whether a connected user, agent, or session may see and invoke a tool. It runs for each caller context.
Quarkus MCP Server discovers annotation-based tools at build time. The OPA filter does not prevent the JVM from loading the class. The extension’s ToolFilter API controls whether a registered tool is visible and accessible for the current MCP connection. We use that hook for per-client exposure.
If Fernbank loaded third-party plugin code dynamically, I would add a catalog-admission job before global registration through ToolManager. A tool registered with ToolManager is available across the server. ToolFilter still decides which subset each client sees.
The request path is:
The Quarkus MCP filter documentation says that filters execute on the Vert.x event loop. They must finish quickly. A synchronous HTTP call to a separate OPA sidecar process would block that thread, so we compile Rego to Wasm and evaluate it in the Quarkus process.
The extension also ignores a filter when that filter throws an exception. Our filter catches policy failures and returns false. A failed policy evaluation therefore denies access.
What You Need
This example uses Quarkus 3.37.2, Quarkus MCP Server 1.13.1, Java 25, OPA 1.17.0, and opa-java-wasm 1.1.0. The verification covers JVM mode. I have not tested this Wasm runtime in a Quarkus native executable, so native mode is outside the scope of this example.
Java 25
Podman with its machine running on macOS or Windows
The Quarkus CLI
Basic MCP and Rego knowledge
About ☕️☕️
Create the Quarkus MCP Server
Create the application or start from my Github repository:
quarkus create app com.themainthread:fernbank-skill-admission \
--platform-bom=io.quarkus.platform:quarkus-bom:3.37.2 \
--java=25 \
--extension=rest-jackson,io.quarkiverse.mcp:quarkus-mcp-server-http \
--no-code
cd fernbank-skill-admissionquarkus-mcp-server-http provides the Streamable HTTP MCP endpoint and the filter API. quarkus-rest-jackson maps manifests, policy input, policy output, and the decision-log endpoint. In this version, the Quarkus platform BOM manages the MCP extension.
Add the Wasm evaluator and the MCP test client to pom.xml:
<properties>
<opa-java-wasm.version>1.1.0</opa-java-wasm.version>
</properties>
<dependencies>
<dependency>
<groupId>com.styra.opa</groupId>
<artifactId>opa-java-wasm</artifactId>
<version>${opa-java-wasm.version}</version>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>The Quarkus guardrail example from the earlier mentioned blog post uses the Styra library too. Our code changes the policy input and evaluates it from an MCP filter.
Use the Manifest as Policy Input
The first tool belongs to Fernbank’s internal documentation team. We put its manifest in src/main/resources/skills/docs_generate.json:
{
"skill_id": "docs_generate",
"publisher": "internal:docs-platform",
"publisher_trust_tier": "internal-verified",
"signature_verified": true,
"requested_scopes": ["context:read", "filesystem:write"],
"declared_capabilities": ["document-generation"],
"allowed_teams": ["content", "platform"]
}The third-party exporter requests filesystem and network access:
{
"skill_id": "pptx_export",
"publisher": "third-party:acme-skills",
"publisher_trust_tier": "third-party-unverified",
"signature_verified": true,
"requested_scopes": ["filesystem:write", "network:egress", "context:read"],
"declared_capabilities": ["document-generation"],
"allowed_teams": ["content"]
}The unsigned internal skill only requests context:read. This lets us test signature enforcement without also triggering a scope rule:
{
"skill_id": "unsigned_status",
"publisher": "internal:ops-lab",
"publisher_trust_tier": "internal-unverified",
"signature_verified": false,
"requested_scopes": ["context:read"],
"declared_capabilities": ["status-reporting"],
"allowed_teams": ["platform"]
}A catalog pipeline can potentially provide these claims. Fernbank verifies the signature before it sets signature_verified. OPA uses the result of that check. It does not verify the signature itself.
Map the JSON with records. SkillManifest.java lists every catalog field used by the policy:
package com.themainthread.fernbank;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
public record SkillManifest(
@JsonProperty("skill_id") String skillId,
String publisher,
@JsonProperty("publisher_trust_tier") String publisherTrustTier,
@JsonProperty("signature_verified") boolean signatureVerified,
@JsonProperty("requested_scopes") List<String> requestedScopes,
@JsonProperty("declared_capabilities") List<String> declaredCapabilities,
@JsonProperty("allowed_teams") List<String> allowedTeams) {
}Keep caller context separate from the manifest:
package com.themainthread.fernbank;
import com.fasterxml.jackson.annotation.JsonProperty;
public record SubjectContext(
@JsonProperty("user_id") String userId,
@JsonProperty("agent_id") String agentId,
@JsonProperty("session_id") String sessionId,
String team) {
}package com.themainthread.fernbank;
import com.fasterxml.jackson.annotation.JsonProperty;
public record AdmissionInput(
SubjectContext subject,
SkillManifest skill,
@JsonProperty("runtime_environment") String runtimeEnvironment,
String action) {
}In this simple example the SkillCatalog loads the three known manifests once at startup. In a real system, this data would come from a database or a signed artifact store. The filter still needs the same lookup method:
package com.themainthread.fernbank;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class SkillCatalog {
private static final List<String> MANIFEST_PATHS = List.of(
"/skills/docs_generate.json",
"/skills/pptx_export.json",
"/skills/unsigned_status.json");
private final ObjectMapper objectMapper;
private Map<String, SkillManifest> manifests;
SkillCatalog(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@PostConstruct
void load() {
manifests = MANIFEST_PATHS.stream()
.map(this::readManifest)
.collect(Collectors.toUnmodifiableMap(SkillManifest::skillId, Function.identity()));
}
public Optional<SkillManifest> find(String skillId) {
return Optional.ofNullable(manifests.get(skillId));
}
private SkillManifest readManifest(String path) {
try (InputStream stream = SkillCatalog.class.getResourceAsStream(path)) {
if (stream == null) {
throw new IllegalStateException("Skill manifest not found: " + path);
}
return objectMapper.readValue(stream, SkillManifest.class);
} catch (IOException e) {
throw new IllegalStateException("Cannot read skill manifest: " + path, e);
}
}
}A skill can pass catalog admission and still be unavailable to a specific team. Caller privileges also cannot make an invalid signature valid.
Return Reasons From Rego
Create src/main/resources/policies/skill-admission.rego:
package fernbank.admission
import rego.v1
policy_version := "2026-07-12"
default allow := false
allowed_scopes := {
"internal-verified": {
"context:read",
"database:read",
"filesystem:read",
"filesystem:write",
"network:egress",
},
"internal-unverified": {
"context:read",
"filesystem:read",
},
"third-party-verified": {
"context:read",
"filesystem:read",
},
"third-party-unverified": {
"context:read",
},
}
known_trust_tier if {
allowed_scopes[input.skill.publisher_trust_tier]
}
scope_allowed(scope) if {
scope in allowed_scopes[input.skill.publisher_trust_tier]
}
team_allowed if {
input.subject.team in input.skill.allowed_teams
}
deny contains {
"code": "TEAM_NOT_AUTHORIZED",
"message": sprintf("team %q is not allowed to use this skill", [input.subject.team]),
} if {
not team_allowed
}
deny contains {
"code": "UNKNOWN_TRUST_TIER",
"message": sprintf("publisher trust tier %q is not configured", [input.skill.publisher_trust_tier]),
} if {
not known_trust_tier
}
deny contains {
"code": "PROD_SIGNATURE_REQUIRED",
"message": "production requires a verified skill signature",
} if {
input.runtime_environment == "prod"
not input.skill.signature_verified
}
deny contains {
"code": "SCOPE_NOT_ALLOWED",
"message": sprintf("scope %q is not allowed for trust tier %q", [scope, input.skill.publisher_trust_tier]),
"scope": scope,
} if {
input.runtime_environment == "prod"
some scope in input.skill.requested_scopes
not scope_allowed(scope)
}
warn contains {
"code": "SIGNATURE_SOFT_FLAG",
"message": "non-production environment accepted an unverified signature",
} if {
input.runtime_environment != "prod"
not input.skill.signature_verified
}
warn contains {
"code": "SCOPE_SOFT_FLAG",
"message": sprintf("non-production environment accepted scope %q outside the tier allowlist", [scope]),
"scope": scope,
} if {
input.runtime_environment != "prod"
some scope in input.skill.requested_scopes
not scope_allowed(scope)
}
allow if count(deny) == 0
outcome := "allow" if allow else := "deny"
enforcement_mode := "enforce" if input.runtime_environment == "prod" else := "warn"
decision := {
"allow": allow,
"enforcement_mode": enforcement_mode,
"outcome": outcome,
"policy_version": policy_version,
"reasons": [reason | some reason in deny],
"warnings": [warning | some warning in warn],
}OPA gives no special meaning to rules named allow and deny. The policy defines how they work together. The OPA policy guide explains complete definitions and defaults. default allow := false ensures that the decision stays defined when no allow rule matches.
Production enforces team membership, known trust tiers, verified signatures, and scope allowlists. Development changes signature and scope findings to warnings. Team isolation remains enforced because development environments can also be shared.
The decision returns reason objects. A full Rego execution trace contains more detail than operators need for a normal authorization event. SCOPE_NOT_ALLOWED is a stable value for logs, tests, dashboards, and incident reviews.
OPA server mode can emit native decision logs with decision IDs. Embedded Wasm has no OPA server, so Fernbank creates an evaluation ID and logs the policy version and reason codes.
Test the policy source before compiling it. Create src/test/resources/policies/skill-admission_test.rego:
package fernbank.admission
import rego.v1
internal_manifest := {
"skill_id": "docs_generate",
"publisher": "internal:docs-platform",
"publisher_trust_tier": "internal-verified",
"signature_verified": true,
"requested_scopes": ["context:read", "filesystem:write"],
"declared_capabilities": ["document-generation"],
"allowed_teams": ["content", "platform"],
}
third_party_manifest := {
"skill_id": "pptx_export",
"publisher": "third-party:acme-skills",
"publisher_trust_tier": "third-party-unverified",
"signature_verified": true,
"requested_scopes": ["filesystem:write", "network:egress", "context:read"],
"declared_capabilities": ["document-generation"],
"allowed_teams": ["content"],
}
unsigned_manifest := {
"skill_id": "unsigned_status",
"publisher": "internal:ops-lab",
"publisher_trust_tier": "internal-unverified",
"signature_verified": false,
"requested_scopes": ["context:read"],
"declared_capabilities": ["status-reporting"],
"allowed_teams": ["platform"],
}
content_subject := {
"agent_id": "fern-assistant",
"session_id": "session-42",
"team": "content",
"user_id": "alice",
}
platform_subject := {
"agent_id": "fern-assistant",
"session_id": "session-43",
"team": "platform",
"user_id": "bob",
}
test_internal_verified_skill_is_allowed_in_prod if {
result := decision with input as {
"action": "mcp:tool:access",
"runtime_environment": "prod",
"skill": internal_manifest,
"subject": content_subject,
}
result.allow
count(result.reasons) == 0
}
test_third_party_write_scope_is_denied_in_prod if {
result := decision with input as {
"action": "mcp:tool:access",
"runtime_environment": "prod",
"skill": third_party_manifest,
"subject": content_subject,
}
not result.allow
"SCOPE_NOT_ALLOWED" in {reason.code | some reason in result.reasons}
}
test_missing_signature_is_denied_in_prod if {
result := decision with input as {
"action": "mcp:tool:access",
"runtime_environment": "prod",
"skill": unsigned_manifest,
"subject": platform_subject,
}
not result.allow
"PROD_SIGNATURE_REQUIRED" in {reason.code | some reason in result.reasons}
}
test_third_party_write_scope_is_soft_flagged_in_dev if {
result := decision with input as {
"action": "mcp:tool:access",
"runtime_environment": "dev",
"skill": third_party_manifest,
"subject": content_subject,
}
result.allow
"SCOPE_SOFT_FLAG" in {warning.code | some warning in result.warnings}
}
test_team_boundary_is_enforced_in_dev if {
result := decision with input as {
"action": "mcp:tool:access",
"runtime_environment": "dev",
"skill": third_party_manifest,
"subject": platform_subject,
}
not result.allow
"TEAM_NOT_AUTHORIZED" in {reason.code | some reason in result.reasons}
}The fifth case checks that team isolation still applies in development. Scope findings become warnings, while the team rule stays enforced.
Compile Rego to Wasm With Podman
Add scripts/build-policy.sh:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
POLICY_DIR="${PROJECT_DIR}/src/main/resources/policies"
TEST_DIR="${PROJECT_DIR}/src/test/resources/policies"
BUNDLE="${PROJECT_DIR}/target/skill-admission-bundle.tar.gz"
EXTRACT_DIR="${PROJECT_DIR}/target/opa-compiled"
mkdir -p "${EXTRACT_DIR}"
podman run --rm \
-v "${POLICY_DIR}:/policy:ro" \
-v "${TEST_DIR}:/tests:ro" \
openpolicyagent/opa:1.17.0 \
test /policy /tests -v
podman run --rm \
-v "${PROJECT_DIR}:/workspace" \
-w /workspace \
openpolicyagent/opa:1.17.0 \
build -t wasm \
-e fernbank/admission/decision \
-o target/skill-admission-bundle.tar.gz \
src/main/resources/policies/skill-admission.rego
tar -xzf "${BUNDLE}" -C "${EXTRACT_DIR}"
install -m 0644 "${EXTRACT_DIR}/policy.wasm" "${POLICY_DIR}/skill-admission.wasm"
echo "Wrote ${POLICY_DIR}/skill-admission.wasm"Make it executable and run it:
chmod +x scripts/build-policy.sh
./scripts/build-policy.shThe five Rego tests finish with:
PASS: 5/5
Wrote .../src/main/resources/policies/skill-admission.wasmThe compiled module becomes part of the application artifact. Updating the policy therefore requires a new build and deployment.
If the security team needs to deploy policy independently, use an OPA service or bundle distribution. The remote decision must happen outside this event-loop filter. That design needs a cache or an asynchronous MCP interception API because a blocking network call inside ToolFilter would block the event loop.
Evaluate OPA In Process
Map the decision with two records:
package com.themainthread.fernbank;
public record PolicyReason(String code, String message, String scope) {
}package com.themainthread.fernbank;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
public record PolicyDecision(
boolean allow,
String outcome,
@JsonProperty("policy_version") String policyVersion,
@JsonProperty("enforcement_mode") String enforcementMode,
List<PolicyReason> reasons,
List<PolicyReason> warnings) {
public static PolicyDecision evaluationFailure(String message) {
return new PolicyDecision(
false,
"deny",
"unavailable",
"fail-closed",
List.of(new PolicyReason("POLICY_EVALUATION_FAILED", message, null)),
List.of());
}
}OpaPolicyEvaluator loads the module once and evaluates the JSON input synchronously:
package com.themainthread.fernbank;
import java.io.IOException;
import java.io.InputStream;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.styra.opa.wasm.OpaPolicy;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class OpaPolicyEvaluator {
private static final String POLICY_PATH = "/policies/skill-admission.wasm";
private final ObjectMapper objectMapper;
private OpaPolicy policy;
OpaPolicyEvaluator(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@PostConstruct
void loadPolicy() {
InputStream stream = OpaPolicyEvaluator.class.getResourceAsStream(POLICY_PATH);
if (stream == null) {
throw new IllegalStateException("OPA policy not found: " + POLICY_PATH);
}
policy = OpaPolicy.builder().withPolicy(stream).build();
}
public PolicyDecision evaluate(AdmissionInput input) {
try {
String resultJson = policy.evaluate(objectMapper.writeValueAsString(input));
JsonNode result = objectMapper.readTree(resultJson).path(0).path("result");
if (result.isMissingNode() || result.isNull()) {
throw new IllegalStateException("OPA returned no decision");
}
return objectMapper.treeToValue(result, PolicyDecision.class);
} catch (IOException | RuntimeException e) {
throw new IllegalStateException("OPA policy evaluation failed", e);
}
}
}If the Wasm file is missing, the application fails during startup. If OPA returns a malformed result, the evaluator throws an exception. The filter catches that exception and denies access. This is required because the extension ignores filter exceptions.
Gate MCP Tools With OPA
Fernbank exposes three demo tools. Each tool name matches its catalog manifest ID:
package com.themainthread.fernbank;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class FernbankTools {
@Tool(
name = "docs_generate",
description = "Generate internal documentation from approved project context.",
annotations = @Tool.Annotations(
title = "Documentation Generator",
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
String generateDocs(@ToolArg(description = "Documentation topic") String topic) {
return "Generated documentation for: " + topic;
}
@Tool(
name = "pptx_export",
description = "Export a presentation through the Acme third-party renderer.",
annotations = @Tool.Annotations(
title = "PPTX Exporter",
readOnlyHint = false,
destructiveHint = false,
idempotentHint = true,
openWorldHint = true))
String exportPresentation(@ToolArg(description = "Presentation title") String title) {
return "Exported presentation: " + title;
}
@Tool(
name = "unsigned_status",
description = "Read deployment status through an unsigned internal lab skill.",
annotations = @Tool.Annotations(
title = "Unsigned Status Reader",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false))
String readStatus(@ToolArg(description = "Service name") String service) {
return service + " is healthy";
}
}OpaToolFilter combines the MCP request, caller data, and skill manifest. The demo reads identity fields from HTTP headers to keep setup small. Caller-supplied headers do not prove identity. Production code should read these values from SecurityIdentity after OpenID Connect (OIDC) verification.
package com.themainthread.fernbank;
import io.quarkiverse.mcp.server.FilterContext;
import io.quarkiverse.mcp.server.ToolFilter;
import io.quarkiverse.mcp.server.ToolManager.ToolInfo;
import io.vertx.core.http.HttpServerRequest;
import jakarta.inject.Singleton;
import org.jboss.logging.Logger;
@Singleton
public class OpaToolFilter implements ToolFilter {
private static final Logger LOG = Logger.getLogger(OpaToolFilter.class);
private final HttpServerRequest request;
private final SkillCatalog catalog;
private final OpaPolicyEvaluator policyEvaluator;
private final DecisionAudit audit;
private final FernbankConfig config;
OpaToolFilter(
HttpServerRequest request,
SkillCatalog catalog,
OpaPolicyEvaluator policyEvaluator,
DecisionAudit audit,
FernbankConfig config) {
this.request = request;
this.catalog = catalog;
this.policyEvaluator = policyEvaluator;
this.audit = audit;
this.config = config;
}
@Override
public boolean test(ToolInfo tool, FilterContext context) {
SkillManifest manifest = catalog.find(tool.name()).orElse(null);
if (manifest == null) {
LOG.errorf("No skill manifest found for MCP tool %s; denying access", tool.name());
return false;
}
SubjectContext subject = new SubjectContext(
header("X-Fernbank-User", "anonymous"),
header("X-Fernbank-Agent", context.connection().initialRequest().implementation().name()),
header("X-Fernbank-Session", "unknown"),
header("X-Fernbank-Team", "none"));
AdmissionInput input = new AdmissionInput(
subject,
manifest,
config.runtimeEnvironment(),
"mcp:tool:access");
PolicyDecision decision;
try {
decision = policyEvaluator.evaluate(input);
} catch (RuntimeException e) {
LOG.errorf(e, "OPA evaluation failed for tool %s; denying access", tool.name());
decision = PolicyDecision.evaluationFailure(e.getMessage());
}
audit.record(input, decision);
return decision.allow();
}
private String header(String name, String defaultValue) {
String value = request.getHeader(name);
return value == null || value.isBlank() ? defaultValue : value;
}
}Version 1.13.1 uses initialRequest().implementation().name(). An older documentation example uses clientInfo().name(), which does not compile with this version. Follow the API in the 1.13.1 artifact here.
What happens to pptx_export in production? Its signature is valid, and the team matches. The tool still disappears because filesystem:write and network:egress are outside the third-party-unverified allowlist. OPA returns one SCOPE_NOT_ALLOWED reason for each scope.
Configure Enforcement by Environment
Create src/main/resources/application.properties:
fernbank.runtime-environment=prod
%dev.fernbank.runtime-environment=dev
%test.fernbank.runtime-environment=prod
quarkus.http.cors.enabled=true
quarkus.http.cors.origins=http://localhost:6274
%dev.quarkus.mcp.server.traffic-logging.enabled=true
%dev.quarkus.mcp.server.traffic-logging.text-limit=1000Map the Fernbank property with typed configuration:
package com.themainthread.fernbank;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "fernbank")
public interface FernbankConfig {
String runtimeEnvironment();
}The server configuration sets the environment. The request has no header that can change production enforcement to warning mode. Tests use production mode and verify the deny path.
The CORS origin matches a local MCP Inspector setup. Replace it with the trusted origin for your client. Traffic logs can contain tool arguments, so this example enables them only in the development profile.
Record Every Decision
Fernbank keeps the last 100 decisions in memory and logs every record as JSON. A denied exporter produces this record:
{
"evaluationId": "c1f10476-26e8-4b72-8df0-c7208457d6a1",
"evaluatedAt": "2026-07-12T03:48:52.158438Z",
"userId": "alice",
"agentId": "fern-assistant",
"sessionId": "session-42",
"team": "content",
"skillId": "pptx_export",
"publisher": "third-party:acme-skills",
"publisherTrustTier": "third-party-unverified",
"signatureVerified": true,
"requestedScopes": ["filesystem:write", "network:egress", "context:read"],
"declaredCapabilities": ["document-generation"],
"runtimeEnvironment": "prod",
"allow": false,
"outcome": "deny",
"enforcementMode": "enforce",
"policyVersion": "2026-07-12",
"reasons": [
{
"code": "SCOPE_NOT_ALLOWED",
"message": "scope \"filesystem:write\" is not allowed for trust tier \"third-party-unverified\"",
"scope": "filesystem:write"
},
{
"code": "SCOPE_NOT_ALLOWED",
"message": "scope \"network:egress\" is not allowed for trust tier \"third-party-unverified\"",
"scope": "network:egress"
}
],
"warnings": [],
"reasonCodes": ["SCOPE_NOT_ALLOWED", "SCOPE_NOT_ALLOWED"],
"warningCodes": []
}The audit record stores caller data, skill data, policy identity, and the result:
package com.themainthread.fernbank;
import java.time.Instant;
import java.util.List;
public record AdmissionAuditRecord(
String evaluationId,
Instant evaluatedAt,
String userId,
String agentId,
String sessionId,
String team,
String skillId,
String publisher,
String publisherTrustTier,
boolean signatureVerified,
List<String> requestedScopes,
List<String> declaredCapabilities,
String runtimeEnvironment,
boolean allow,
String outcome,
String enforcementMode,
String policyVersion,
List<PolicyReason> reasons,
List<PolicyReason> warnings,
List<String> reasonCodes,
List<String> warningCodes) {
}DecisionAudit limits the local buffer to 100 entries. It also sorts reason codes because Rego produces sets and does not guarantee their order:
package com.themainthread.fernbank;
import java.time.Instant;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.UUID;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
@ApplicationScoped
public class DecisionAudit {
private static final Logger LOG = Logger.getLogger(DecisionAudit.class);
private static final int CAPACITY = 100;
private final ObjectMapper objectMapper;
private final Deque<AdmissionAuditRecord> records = new ArrayDeque<>(CAPACITY);
DecisionAudit(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
public synchronized AdmissionAuditRecord record(AdmissionInput input, PolicyDecision decision) {
AdmissionAuditRecord record = new AdmissionAuditRecord(
UUID.randomUUID().toString(),
Instant.now(),
input.subject().userId(),
input.subject().agentId(),
input.subject().sessionId(),
input.subject().team(),
input.skill().skillId(),
input.skill().publisher(),
input.skill().publisherTrustTier(),
input.skill().signatureVerified(),
List.copyOf(input.skill().requestedScopes()),
List.copyOf(input.skill().declaredCapabilities()),
input.runtimeEnvironment(),
decision.allow(),
decision.outcome(),
decision.enforcementMode(),
decision.policyVersion(),
List.copyOf(decision.reasons()),
List.copyOf(decision.warnings()),
codes(decision.reasons()),
codes(decision.warnings()));
if (records.size() == CAPACITY) {
records.removeFirst();
}
records.addLast(record);
log(record);
return record;
}
public synchronized List<AdmissionAuditRecord> recent(int limit) {
int safeLimit = Math.max(1, Math.min(limit, CAPACITY));
List<AdmissionAuditRecord> snapshot = new ArrayList<>(records);
int fromIndex = Math.max(0, snapshot.size() - safeLimit);
return List.copyOf(snapshot.subList(fromIndex, snapshot.size()));
}
private List<String> codes(List<PolicyReason> reasons) {
return reasons.stream().map(PolicyReason::code).sorted().toList();
}
private void log(AdmissionAuditRecord record) {
try {
LOG.infof("opa_decision=%s", objectMapper.writeValueAsString(record));
} catch (JsonProcessingException e) {
LOG.warnf(e, "Could not serialize OPA decision %s", record.evaluationId());
}
}
}Expose the buffer through DecisionResource.java:
package com.themainthread.fernbank;
import java.util.List;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
@Path("/api/decisions")
@Produces(MediaType.APPLICATION_JSON)
public class DecisionResource {
private final DecisionAudit audit;
DecisionResource(DecisionAudit audit) {
this.audit = audit;
}
@GET
public List<AdmissionAuditRecord> recent(@QueryParam("limit") @DefaultValue("20") int limit) {
return audit.recent(limit);
}
}The in-memory buffer keeps the example self-contained. Production should send these records to an append-only audit store with retention, access control, and a policy-bundle digest. The application-generated UUID connects related logs. It is different from OPA’s native decision_id because the Wasm path has no OPA server.
Test Tool Visibility and Direct Calls
The McpAssured test client speaks Streamable HTTP and supports custom request headers. The test lists the visible tools, calls a hidden tool by name, and checks the audit endpoint:
package com.themainthread.fernbank;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.hasItem;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import io.quarkiverse.mcp.server.test.McpAssured;
import io.quarkiverse.mcp.server.test.McpAssured.McpStreamableTestClient;
import io.quarkus.test.junit.QuarkusTest;
import io.vertx.core.MultiMap;
@QuarkusTest
class ToolExposureTest {
@Test
void productionClientOnlySeesAdmittedTools() {
McpStreamableTestClient client = clientFor("alice", "content");
try {
client.when()
.toolsList(page -> {
assertEquals(1, page.size());
assertNotNull(page.findByName("docs_generate"));
assertFalse(page.tools().stream().anyMatch(tool -> tool.name().equals("pptx_export")));
assertFalse(page.tools().stream().anyMatch(tool -> tool.name().equals("unsigned_status")));
})
.thenAssertResults();
client.when()
.toolsCall("pptx_export")
.withArguments(Map.of("title", "Quarterly review"))
.withErrorAssert(error -> assertTrue(error.message().contains("pptx_export")))
.send()
.thenAssertResults();
given()
.queryParam("limit", 20)
.when().get("/api/decisions")
.then()
.statusCode(200)
.body("skillId", hasItem("pptx_export"))
.body("reasonCodes.flatten()", hasItem("SCOPE_NOT_ALLOWED"));
} finally {
client.disconnect();
}
}
private McpStreamableTestClient clientFor(String user, String team) {
return McpAssured.newStreamableClient()
.setAdditionalHeaders(message -> {
MultiMap headers = MultiMap.caseInsensitiveMultiMap();
headers.add("X-Fernbank-User", user);
headers.add("X-Fernbank-Agent", "fern-assistant");
headers.add("X-Fernbank-Session", UUID.randomUUID().toString());
headers.add("X-Fernbank-Team", team);
return headers;
})
.build()
.connect();
}
}Run the suite:
./mvnw testExpected result:
Tests run: 5, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESSThe first assertion checks client-specific exposure. The second checks that a direct call to a hidden tool also fails. The final assertion checks that the policy decision reached the audit log.
Four other tests check the policy result directly:
Internal, verified, narrow scopes in production: allow
Third-party, unverified tier with filesystem and network scopes in production: deny with
SCOPE_NOT_ALLOWEDMissing signature in production: deny with
PROD_SIGNATURE_REQUIREDThe third-party exporter in development: allow with
SCOPE_SOFT_FLAG
Run Fernbank
Start Quarkus:
./mvnw quarkus:devThe Streamable HTTP endpoint is http://localhost:8080/mcp. In dev mode, scope violations produce warnings. A content-team client therefore sees docs_generate and pptx_export. It does not see unsigned_status because that tool only allows the platform team.
To inspect the production deny behavior in dev mode, override the Fernbank environment:
./mvnw quarkus:dev -Dfernbank.runtime-environment=prodAfter an MCP client lists tools, inspect the recent decisions:
curl -s http://localhost:8080/api/decisions | jqRego reason collections are sets, so their order may change. Consumers should use the stable code values and ignore array order.
Before Production
Three parts need to change before production.
Verify identity. Replace the X-Fernbank-* headers with an OIDC bearer token and values from Quarkus SecurityIdentity. A reverse proxy may add trusted headers after authentication. The application must still prevent callers from setting those headers directly.
Store audit records outside the process. Send each record to an append-only system. Include a digest or revision for the policy bundle, manifest, and deployed tool artifact. The date in policy_version is easy to read, while content hashes identify the exact files that ran. Remove /api/decisions or protect it with an operator role because the response contains user, session, publisher, and scope data.
Choose how policy is deployed. Embedded Wasm keeps evaluation local and gives us a simple failure mode. It also deploys policy with the application. A central OPA service supports independent policy rollout and native decision logs. The current synchronous ToolFilter cannot wait for that service without blocking the event loop. Use precomputed permissions, a local bundle agent, or an asynchronous interception point.
Tool annotations such as readOnlyHint help MCP clients understand intent. They do not enforce authorization. Fernbank evaluates the signed catalog manifest and caller context, then filters the MCP protocol. The tool implementation still needs normal authorization at the business boundary because another endpoint or an internal Java call may reach the same operation.
Conclusion
Fernbank evaluates skill metadata and caller identity before Quarkus exposes or invokes an MCP tool. The model has no role in that decision. A denial includes the policy version, caller context, and reason code that an operator can inspect later.



