How to Authorize Stateless MCP Tools with Quarkus
Follow one request through protocol checks, OIDC, OPA, and an argument guardrail before the Java tool method runs.
I was writing my quarterly article for the German Java SPEKTRUM magazine. The topic had me thinking about agent identities, Java, and specifications. One question keps coming back: when an agent calls a Java service, where should I check its identity?
Then my coworker Daniel Oh published Hardening MCP Gateways: Mitigating - Security Risks in Java Applications. His article looks at the new MCP protocol headers and the security checks a gateway can make. I also just recently published Guardrails with OPA Policies in Quarkus LangChain4j, which gave me an OPA and WebAssembly setup I already knew.
At that point, I had an itch to test the new specification and mix the earlier work together.
Quarkus MCP Server 2.0.0.Beta3 supports the new stateless MCP protocol while the API is still changing. The initialize handshake and the long-lived session are gone for this protocol version. Each request carries the protocol data the server needs. I wanted to see what the new specification gives me, what Quarkus already checks, and what still belongs in my Java code.
So I mixed the pieces from those earlier articles. I used the new MCP headers, OIDC identity, an OPA policy for tool access, and a normal Java check for the tool arguments.
My first request was wrong on purpose. The Mcp-Name header says pptx_export, while the JSON-RPC body calls docs_generate. Quarkus rejects it with this response:
{
"jsonrpc": "2.0",
"error": {
"code": -32020,
"message": "Header mismatch: Mcp-Name header value 'pptx_export' does not match body value 'docs_generate'"
}
}Good. That is the first boundary I wanted to see. The 2026-07-28 release candidate requires servers to compare the headers with the body. Beta3 does this before my tool code runs.
The second request was more interesting. The headers matched. The token was valid, and the arguments passed JSON schema validation. The caller still had no permission to use the tool. At that point, the application has to decide.
I needed a small system for the test, so I built Fernbank. Think of it as an internal agent platform with a few document and presentation tools. A request passes through four checks before the Java method runs:
I keep the checks separate because each one answers a different question:
Do the MCP headers, metadata, and JSON-RPC body describe the same request?
Who made the request, and was the token issued for this server?
May this principal discover and invoke this tool?
May the principal perform this specific operation with these arguments?
The protocol check compares the headers with the body. OIDC tells me who sent the request. OPA decides which tools that person may see and call. The last guardrail checks the requested operation and its arguments.
What I Built
Here is the stack I used:
Quarkus 3.37.3
Quarkus MCP Server 2.0.0.Beta3
the stateless MCP
2026-07-28protocolQuarkus OIDC and
SecurityIdentityan OPA policy compiled to WebAssembly and evaluated in-process
ToolFilterfor discovery and direct-call admission@ToolGuardrailsfor argument-level authorization
This is an early experiment on a beta release. The Quarkiverse 2.0 release notes describe the stateless support, and the transport guide lists the Beta3 dependency coordinates.
You need Java 21 and Podman to follow along. I tested the JVM build. I did not test native compilation with opa-java-wasm.
You need Java 21 and Podman to follow along. I tested the JVM build. I did not test native compilation with opa-java-wasm, so I leave that out here.
This is based on the earlier Fernbank example so I just used that as a base and created a new one. This time you have to start by cloning the example and enter the module:
git clone https://github.com/myfear/the-main-thread.git
cd the-main-thread/quarkus-mcp-layered-authorizationAdd Both BOMs
Version management needs one extra step. The Quarkus platform BOM does not manage Quarkus MCP Server 2.0, so I import the Quarkus BOM and the Quarkiverse MCP BOM separately:
<properties>
<maven.compiler.release>21</maven.compiler.release>
<quarkus.platform.version>3.37.3</quarkus.platform.version>
<quarkus-mcp-server.version>2.0.0.Beta3</quarkus-mcp-server.version>
<opa-java-wasm.version>1.1.0</opa-java-wasm.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>io.quarkus.platform</groupId>
<artifactId>quarkus-bom</artifactId>
<version>${quarkus.platform.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-bom</artifactId>
<version>${quarkus-mcp-server.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>After that, I add the application and test dependencies:
<dependencies>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-http</artifactId>
</dependency>
<dependency>
<groupId>io.quarkiverse.mcp</groupId>
<artifactId>quarkus-mcp-server-oidc</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-arc</artifactId>
</dependency>
<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>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-test-security</artifactId>
<scope>test</scope>
</dependency>
</dependencies>Two dependencies are necessary for security in this example. quarkus-mcp-server-oidc adds the MCP-specific authentication failure response described in the Quarkiverse security guide. Quarkus OIDC validates the bearer token.
Get the Identity From OIDC
Every later decision needs an identity I can trust. I start by protecting the MCP endpoint and its subpaths in application.properties:
fernbank.runtime-environment=prod
%dev.fernbank.runtime-environment=dev
%test.fernbank.runtime-environment=prod
quarkus.http.auth.permission.mcp.paths=/mcp,/mcp/*
quarkus.http.auth.permission.mcp.policy=authenticated
quarkus.http.cors.enabled=true
quarkus.http.cors.origins=${FERNBANK_MCP_CORS_ORIGINS:http://localhost:6274}
%prod.quarkus.oidc.auth-server-url=${FERNBANK_OIDC_AUTH_SERVER_URL}
%prod.quarkus.oidc.client-id=fernbank-mcp
%prod.quarkus.oidc.application-type=service
%prod.quarkus.oidc.token.audience=fernbank-mcp
%prod.quarkus.oidc.resource-metadata.enabled=true
%dev.quarkus.oidc.tenant-enabled=false
%test.quarkus.oidc.tenant-enabled=falseThe audience check stops Fernbank from accepting a valid token issued for another service. I also enable protected-resource metadata. Compatible MCP clients can then find the authorization server.
The Streamable HTTP transport expects the Quarkus CORS filter to be active. I allow the local MCP Inspector origin by default. For a deployed browser client, I set FERNBANK_MCP_CORS_ORIGINS to the exact origin instead of opening it for every site.
For tests, I disable the external OIDC tenant. quarkus-test-security gives each test a verified identity, so I do not need a running identity provider in the test suite. The production profile always uses the real OIDC configuration.
From here on, I use SecurityIdentity as the identity source. I never copy the user or team from headers such as X-User or X-Team. A gateway may add trusted identity headers, but then the application must stop clients from sending or replacing them. Using the identity that Quarkus already verified is simpler and gives me one clear source.
Put Tool Risk in a Manifest
OIDC tells me who is calling. I still need facts about the tool itself: who published it, which scopes it asks for, and which teams may use it. I keep those facts in a manifest beside every registered tool.
The first one is 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 presentation exporter is different. It comes from a third party and asks for scopes outside its trust tier:
{
"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 status tool asks for little access. Its signature is missing:
{
"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"]
}Now I have two inputs for the decision. The token gives me the principal and roles. The catalog adds the publisher, signature state, scopes, and allowed teams. OPA can use all of them together.
package com.themainthread.fernbank;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
public record SubjectContext(
@JsonProperty("principal_name") String principalName,
List<String> roles) {
}package com.themainthread.fernbank;
import com.fasterxml.jackson.annotation.JsonProperty;
public record AdmissionInput(
SubjectContext subject,
SkillManifest skill,
@JsonProperty("runtime_environment") String runtimeEnvironment,
String action) {
}I also keep the application configuration typed:
package com.themainthread.fernbank;
import io.smallrye.config.ConfigMapping;
@ConfigMapping(prefix = "fernbank")
public interface FernbankConfig {
String runtimeEnvironment();
}Move Tool Admission Into Rego
The earlier OPA guardrail article used an in-process WebAssembly policy to check prompt text. I reuse the same basic evaluator here, but the input is now identity and tool catalog data. If OPA and WebAssembly are new to you, the earlier article explains that setup in more detail.
The policy maps allowed scopes to each publisher trust tier. It also checks team membership and requires a verified signature in production. Save it as src/main/resources/policies/skill-admission.rego:
package fernbank.admission
import rego.v1
policy_version := "2026-07-22"
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 {
some role in input.subject.roles
role in input.skill.allowed_teams
}
deny contains {
"code": "TEAM_NOT_AUTHORIZED",
"message": sprintf(
"none of principal %q's roles may use this skill",
[input.subject.principal_name],
),
} 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],
}I use deny as the default. The policy also returns stable reason codes such as SCOPE_NOT_ALLOWED. My tests and audit searches use those codes, while the human-readable message can change later.
The build script runs the Rego tests and then compiles the policy to WebAssembly:
./scripts/build-policy.shThe script uses Podman and writes src/main/resources/policies/skill-admission.wasm.
Why run the policy inside Quarkus? MCP filters run on the Vert.x event loop, and this filter is synchronous. A network call to a remote OPA service would block that thread. Local WebAssembly evaluation avoids that network call and returns the decision in the same process.
Apply Policy to Listing and Calling
Tool discovery is one check. A client can also send a direct tools/call with a tool name it already knows. I need the same policy decision for listing and calling.
Quarkus applies ToolFilter in both places:
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.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.control.ActivateRequestContext;
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 SecurityIdentity identity;
private final SkillCatalog catalog;
private final OpaPolicyEvaluator policyEvaluator;
private final DecisionAudit audit;
private final FernbankConfig config;
OpaToolFilter(
SecurityIdentity identity,
SkillCatalog catalog,
OpaPolicyEvaluator policyEvaluator,
DecisionAudit audit,
FernbankConfig config) {
this.identity = identity;
this.catalog = catalog;
this.policyEvaluator = policyEvaluator;
this.audit = audit;
this.config = config;
}
@Override
@ActivateRequestContext
public boolean test(ToolInfo tool, FilterContext context) {
try {
if (identity.isAnonymous()) {
LOG.warnf("No authenticated identity available for MCP tool %s; denying access", tool.name());
return false;
}
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(
identity.getPrincipal().getName(),
identity.getRoles().stream().sorted().toList());
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,
String.valueOf(context.requestId()),
context.connection().isTransient());
return decision.allow();
} catch (RuntimeException e) {
LOG.errorf(e, "MCP authorization failed for tool %s; denying access", tool.name());
return false;
}
}
}The filter reads the principal and roles from SecurityIdentity. If a manifest is missing, it returns false. This means a new tool method stays hidden until I add its policy data.
There is a challenge here that I discovered. SecurityIdentity is request-scoped. Quarkus MCP normally activates that context for an MCP request, but I still put @ActivateRequestContext on the filter. If another callback reaches it without the normal request lifecycle, the proxy remains usable and resolves to an anonymous identity. I reject that identity before I build the OPA input.
The filter documentation has another detail that changed my implementation slightly: Quarkus ignores a filter exception and continues with the next filter. An exception alone does not deny access. The outer try therefore covers identity lookup, catalog access, OPA, and audit recording. Any unexpected runtime failure returns false. The inner catch keeps an OPA failure as an audit decision before denying the tool.
For a stateless request, context.connection().isTransient() returns true. I store that value with the JSON-RPC request ID in the audit record. The new protocol has no MCP session ID for me to record.
Check the Tool Arguments Too
At this point, Alice can see and call docs_generate because she belongs to the content team. OPA has approved access to the tool. The destination still needs its own authorization check.
For example, Alice should not write a document into the platform team’s destination. Both destination values are valid strings, so JSON schema validation cannot decide this.
Beta3’s input guardrail runs after tool selection and JSON parsing, but before the method. At that point, I can inspect the actual arguments:
package com.themainthread.fernbank;
import io.quarkiverse.mcp.server.ToolCallException;
import io.quarkiverse.mcp.server.ToolInputGuardrail;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import org.jboss.logging.Logger;
@ApplicationScoped
public class DestinationTeamGuardrail implements ToolInputGuardrail {
private static final Logger LOG = Logger.getLogger(DestinationTeamGuardrail.class);
private final SecurityIdentity identity;
DestinationTeamGuardrail(SecurityIdentity identity) {
this.identity = identity;
}
@Override
public void apply(ToolInputContext context) {
String destinationTeam = context.getArguments().getString("destinationTeam");
if (destinationTeam == null || destinationTeam.isBlank()) {
throw new ToolCallException("destinationTeam is required");
}
if (!identity.hasRole(destinationTeam)) {
LOG.warnf(
"tool_argument_denied principal=%s tool=%s destination_team=%s request_id=%s",
identity.getPrincipal().getName(),
context.getTool().name(),
destinationTeam,
context.getRequestId());
throw new ToolCallException(
"Principal %s cannot generate documents for team %s"
.formatted(identity.getPrincipal().getName(), destinationTeam));
}
}
}Then I attach the guardrail to the tool method:
package com.themainthread.fernbank;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolGuardrails;
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))
@ToolGuardrails(input = DestinationTeamGuardrail.class)
String generateDocs(
@ToolArg(description = "Documentation topic") String topic,
@ToolArg(description = "Team that owns the generated document") String destinationTeam) {
return "Generated documentation for %s: %s".formatted(destinationTeam, 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";
}
}The guardrail checks the relationship between Alice and the destination team. I like this boundary because it is easy to explain: once the request reaches the tool, we are back to normal business authorization.
I still keep the final ownership check inside the service that writes the data. The guardrail rejects the MCP request early, which is good for the caller. Another REST endpoint or an internal Java call could reach the same service, so database rows and deployment environments still need protection below the MCP layer.
Test Each Boundary
With four checks in one request path, a test that only says “access denied” is not enough for me. I want to know which boundary rejected the request and why.
Quarkus MCP Beta3’s test client can speak the stateless protocol directly. setStateless() uses server/discover and creates a transient connection for every request.
The first test authenticates Alice with the content and auditor roles. OPA should leave only docs_generate in her tool list:
package com.themainthread.fernbank;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.blankOrNullString;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItem;
import static org.hamcrest.Matchers.not;
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 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.quarkus.test.security.TestSecurity;
@QuarkusTest
class ToolExposureTest {
@Test
@TestSecurity(user = "alice", roles = { "content", "mcp-auditor" })
void productionClientOnlySeesAdmittedTools() {
McpStreamableTestClient client = McpAssured.newStreamableClient()
.setStateless()
.build()
.connect();
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"))
.body("find { it.skillId == 'pptx_export' }.transientConnection", equalTo(true))
.body("find { it.skillId == 'pptx_export' }.requestId", not(blankOrNullString()));
} finally {
client.disconnect();
}
}
}The same test calls pptx_export directly. This proves that the filter also protects invocation. If I only hide the tool from the list, a client that knows its name can still try to call it.
Next, I test authentication and the protocol headers with raw HTTP requests:
package com.themainthread.fernbank;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import java.util.Map;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import io.quarkus.test.security.TestSecurity;
@QuarkusTest
class ProtocolBoundaryTest {
private static final String PROTOCOL_VERSION = "2026-07-28";
private static final String NAME_MISMATCH = "Header mismatch: Mcp-Name header value 'pptx_export' "
+ "does not match body value 'docs_generate'";
@Test
void rejectsUnauthenticatedRequestsBeforeProtocolParsing() {
given()
.contentType("application/json")
.accept("application/json, text/event-stream")
.body("{}")
.when().post("/mcp")
.then()
.statusCode(401);
}
@Test
@TestSecurity(user = "alice", roles = "content")
void rejectsAHeaderBodyToolNameMismatch() {
given()
.contentType("application/json")
.accept("application/json, text/event-stream")
.header("Mcp-Protocol-Version", PROTOCOL_VERSION)
.header("Mcp-Method", "tools/call")
.header("Mcp-Name", "pptx_export")
.body(Map.of(
"jsonrpc", "2.0",
"id", 1,
"method", "tools/call",
"params", Map.of(
"name", "docs_generate",
"arguments", Map.of(
"topic", "Quarterly controls",
"destinationTeam", "content"),
"_meta", statelessMetadata())))
.when().post("/mcp")
.then()
.statusCode(400)
.body("error.code", equalTo(-32020))
.body("error.message", equalTo(NAME_MISMATCH));
}
private Map<String, Object> statelessMetadata() {
return Map.of(
"io.modelcontextprotocol/protocolVersion", PROTOCOL_VERSION,
"io.modelcontextprotocol/clientInfo", Map.of(
"name", "fernbank-test-client",
"version", "1.0"),
"io.modelcontextprotocol/clientCapabilities", Map.of());
}
}The first request has no identity, so it returns 401. The second request uses a valid test identity and sends different tool names in the header and body. Beta3 returns the exact -32020 mismatch error before my filter or tool method runs.
The last test class reaches the argument boundary. It checks a destination that Alice may not use and one that she may use:
package com.themainthread.fernbank;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
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.quarkus.test.security.TestSecurity;
@QuarkusTest
class ToolArgumentAuthorizationTest {
@Test
@TestSecurity(user = "alice", roles = "content")
void rejectsAValidToolCallForAnotherTeam() {
McpStreamableTestClient client = McpAssured.newStreamableClient()
.setStateless()
.build()
.connect();
try {
client.when()
.toolsCall("docs_generate")
.withArguments(Map.of(
"topic", "Quarterly controls",
"destinationTeam", "platform"))
.withAssert(response -> {
assertTrue(response.isError());
assertTrue(response.firstContent().asText().text().contains("platform"));
})
.send()
.thenAssertResults();
} finally {
client.disconnect();
}
}
@Test
@TestSecurity(user = "alice", roles = "content")
void acceptsAValidToolCallForTheCallersTeam() {
McpStreamableTestClient client = McpAssured.newStreamableClient()
.setStateless()
.build()
.connect();
try {
client.when()
.toolsCall("docs_generate")
.withArguments(Map.of(
"topic", "Quarterly controls",
"destinationTeam", "content"))
.withAssert(response -> assertTrue(response.firstContent().asText().text().contains("content")))
.send()
.thenAssertResults();
} finally {
client.disconnect();
}
}
}Now run the suite:
./mvnw testAll ten Java tests pass. One of them calls the filter directly without an authenticated request context and checks that it returns false. I added that test because a normal MCP call can hide this class of failure: Beta3 logs a filter exception and continues. The policy build also runs five Rego tests, and they pass as well. The Quarkiverse testing guide has more details about the MCP test client.
What I Would Change for Production
This experiment packages the OPA bundle and the tool manifests inside the application. For production, I would change that lifecycle. A real tool catalog will probably change faster than the server.
I would sign the bundle and verify its digest before activation. I would also keep the previous known-good revision and record the policy and manifest digests with every decision. That gives an operator enough data to find the exact policy that allowed a specific call.
The audit endpoint uses a small in-memory buffer because this is only a demo. In a real system, I would send the events to an append-only store and protect access with an operator role. Access tokens, complete prompts, and confidential tool arguments stay out of the log.
Remote policy data needs a different design too. ToolFilter is synchronous, so the event-loop thread must not wait for a policy service over the network. I would precompute a local authorization view or move the lookup to an asynchronous interception point.
OIDC validates the token and its audience. It cannot tell me what the user meant when an agent acts on its own. High-impact tools still need narrow scopes, checks on the target resource, bounded arguments, and a confirmation before a serious side effect.
Finally, failures should be easy to diagnose. The logs and tests should tell me whether the request had invalid protocol data, no identity, a denied tool, or a denied operation.
Where the New Headers Help
So, what did I get from this experiment?
Mcp-Method and Mcp-Name make the request easier for HTTP infrastructure to read. A gateway can route calls, collect metrics, and reject inconsistent data without parsing the complete MCP body.
The headers also help Quarkus check that the protocol data is consistent. After that, OIDC establishes the principal, OPA filters the tools, and the input guardrail checks the requested operation.
That split is much clearer now. The stateless protocol gives the gateway and the server better request data. My application still owns authorization.



