Part 2: Securing and Scaling Goose-to-Java Agent Traffic With agentgateway
Deploy agentgateway as a security proxy between Goose AI agents and Quarkus MCP servers to enforce JWT auth, RBAC, and tool-poisoning guardrails.
Join the DZone community and get the full member experience.
Join For FreeIn Part 1 of this series, we built a Quarkus-based MCP tool server and connected it to the Goose AI agent over Streamable HTTP. The tools worked, the demo was clean, and everything ran on localhost. But the moment you imagine 50 developers running Goose on their laptops, all hitting the same set of backend MCP servers, the architecture starts to crack. Who authenticated that tool call? Which role authorized the getAuditTrail invocation? What stops a poisoned tool name from injecting payloads into your backend?
This article answers those questions by placing agentgateway — the Linux Foundation's open-source proxy for agentic AI traffic — between Goose clients and the Quarkus MCP microservices we built in Part 1.
The Problem: Direct Agent-to-Backend Connections Don't Scale
When Goose (or any MCP client) connects directly to a backend MCP server, every tool call is a point-to-point trust relationship:
This works for demos. It breaks in production for three reasons:
- No authentication. The MCP Streamable HTTP endpoint accepts any JSON-RPC call. There is no token verification, no session binding, and no identity propagation.
- No authorization. Every caller can invoke every tool. An intern running Goose has the same access as an SRE —
getAuditTrail,getOrderStatus, everything. - No guardrails. A compromised or misconfigured agent can send tool names containing prototype-pollution payloads (
__proto__), path-traversal sequences (../), or CRLF-injected headers. The backend has to defend itself alone.
The Solution: agentgateway as a Unified Control Plane
agentgateway is a Rust-based proxy purpose-built for AI agent traffic. It understands the MCP protocol natively — it doesn't just forward HTTP; it parses JSON-RPC envelopes, manages MCP sessions, and applies policies at the tool-call level. Here is the architecture we're building:
Goose connects to agentgateway on port 3000. agentgateway validates the JWT, checks the caller's roles against tool-level RBAC rules, passes the call through an ExtMCP guardrail server that sanitizes headers and blocks poisoning attempts, and only then forwards the clean request to the Quarkus backend on port 8080.
Prerequisites
You'll need everything from Part 1, plus:
agentgateway binary (v1.4+):
curl -sL https://agentgateway.dev/install | bash
Verify your Part 1 Quarkus MCP server still works:
cd part1-quarkus-mcp
mvn quarkus:dev
Then confirm the MCP endpoint responds:
curl -s http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' | jq .
Step 1: Deploy agentgateway Alongside the Quarkus MCP Server
Create the agentgateway configuration at part2-agentgateway/agentgateway/config-dev.yaml. This development config proxies MCP traffic without requiring JWT, so you can validate the plumbing first:
# yaml-language-server: $schema=https://agentgateway.dev/schema/config
mcp:
port: 3000
policies:
cors:
allowOrigins:
- "*"
allowHeaders:
- mcp-protocol-version
- content-type
- mcp-session-id
exposeHeaders:
- Mcp-Session-Id
targets:
- name: customer-tools
mcp:
host: http://localhost:8080/mcp
Start agentgateway:
agentgateway -f part2-agentgateway/agentgateway/config-dev.yaml
Now test the proxied MCP endpoint. Note that agentgateway returns SSE format (event: message\ndata: {...}), so we extract the JSON from the data: line:
curl -s http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "MCP-Protocol-Version: 2025-03-26" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}' \
| grep '^data: ' | sed 's/^data: //' | jq .
You should see the same customer-tools server info as Part 1, but the traffic now flows through agentgateway. Open http://localhost:15000/ui to see the agentgateway admin UI with your MCP target listed.
Step 2: Add JWT Authentication
With the proxy working, let's lock it down. The mcpAuthentication policy implements the MCP Authorization specification — it validates JWT bearer tokens on every MCP request and supports OAuth 2.1 with PKCE for browser-based flows.
Update the config to part2-agentgateway/agentgateway/config.yaml:
# yaml-language-server: $schema=https://agentgateway.dev/schema/config
mcp:
port: 3000
policies:
cors:
allowOrigins:
- "*"
allowHeaders:
- mcp-protocol-version
- content-type
- mcp-session-id
- authorization
exposeHeaders:
- Mcp-Session-Id
mcpAuthentication:
issuer: http://localhost:9000
audiences:
- "http://localhost:3000/mcp"
jwks:
url: http://localhost:9000/.well-known/jwks.json
resourceMetadata:
resource: http://localhost:3000/mcp
scopesSupported:
- "mcp:tools:read"
- "mcp:tools:execute"
bearerMethodsSupported:
- header
targets:
- name: customer-tools
mcp:
host: http://localhost:8080/mcp
How It Works
When a Goose client (or any MCP client) connects to http://localhost:3000/mcp:
- Discovery. The client fetches
/.well-known/oauth-protected-resourcefrom agentgateway and discovers it needs a bearer token with themcp:tools:executescope. - Token acquisition. The client runs the OAuth 2.1 Authorization Code flow with PKCE against the issuer (
http://localhost:9000), obtains an access token, and includes it asAuthorization: Beareron subsequent MCP requests. - Validation. agentgateway downloads the JWKS from the issuer, verifies the token signature, checks
exp,iss, andaudclaims, and extracts thesuband role claims for downstream authorization. - Forwarding. Only after validation does agentgateway forward the JSON-RPC call to the Quarkus backend.
Connecting to a Real OIDC Provider
For production, replace the issuer and JWKS URL with your OIDC provider. Here is an example using Keycloak:
mcpAuthentication:
issuer: https://keycloak.example.com/realms/mcp
audiences:
- "https://gateway.example.com/mcp"
jwks:
url: https://keycloak.example.com/realms/mcp/protocol/openid-connect/certs
provider:
keycloak: {}
agentgateway has built-in support for Keycloak, Auth0, Okta, Microsoft Entra ID, and other OIDC providers.
Step 3: Configure Tool-Level RBAC With CEL Expressions
JWT authentication tells you who is calling. MCP authorization tells you what they're allowed to do. agentgateway uses CEL (Common Expression Language) to define fine-grained, tool-level RBAC rules.
Add the mcpAuthorization policy to your config:
mcpAuthorization:
rules:
# Operators can call any tool
- 'has(jwt.roles) && "operator" in jwt.roles'
# Viewers can only read status and health
- >
has(jwt.roles) && "viewer" in jwt.roles &&
mcp.tool.name in ["getCustomerStatus", "getZoneHealthLogs", "getSLACompliance"]
# Auditors can access audit trail and SLA compliance
- >
has(jwt.roles) && "auditor" in jwt.roles &&
mcp.tool.name in ["getAuditTrail", "getSLACompliance"]
How the Rules Work
Each rule is a CEL expression that evaluates to true (allow) or false (deny). agentgateway evaluates them in order — the first match wins.
| Role | Allowed Tools | Denied Tools |
|---|---|---|
operator |
All five tools | None |
viewer |
getCustomerStatus, getZoneHealthLogs, getSLACompliance |
getOrderStatus, getAuditTrail |
auditor |
getAuditTrail, getSLACompliance |
getCustomerStatus, getZoneHealthLogs, getOrderStatus |
| No role | None | All |
These aren't abstract labels — at Acme FinServ they map to real people and a real segregation-of-duties story:
| Persona | Role | Why this scope |
|---|---|---|
| Sofia — SRE, on-call for the platform | operator |
Needs to drive operational tools during incidents; full access is justified and logged. |
| Acme Status Dashboard — an internal read-only service | viewer |
Shows customers and health at a glance; must never read getOrderStatus or getAuditTrail (PII/financial). |
| Priya — external SOC 2 auditor | auditor |
Reviews the audit trail and SLA posture only. Giving her getCustomerStatus would violate least privilege — an auditor reading live customer data is itself a finding. |
The auditor scope is the one a SOC 2 assessor will scrutinize: it proves the audit function is separated from the operational function, and that access is granted by need, not convenience.
agentgateway also auto-filters tools/list responses — if a viewer calls tools/list, they only see the three tools they're authorized to invoke. The agent never even learns that getAuditTrail exists.
Available CEL Variables
| Variable | Description |
|---|---|
mcp.tool.name |
The tool being invoked (e.g., getCustomerStatus) |
mcp.tool.target |
The backend target name (e.g., customer-tools) |
jwt.sub |
The subject claim from the JWT |
jwt.roles |
Role claims extracted from the JWT |
has(jwt. |
Check whether a JWT claim exists |
Step 4: Prevent Tool Poisoning With ExtMCP Guardrails
JWT and RBAC protect the identity layer. Guardrails protect the content layer. A valid, authenticated operator can still send a tool call with a poisoned name like getCustomerStatus/../../../etc/passwd or arguments containing tags. The Quarkus backend's @Pattern annotations from Part 1 catch some of this, but defense in depth means filtering at the proxy too.
agentgateway's ExtMCP guardrails intercept MCP method calls before they reach the backend, passing them through an external gRPC policy server that can inspect, mutate, or deny each call.
Building the Guardrail Server With Quarkus gRPC
Instead of relying on a third-party Docker image, we'll build our own ExtMCP guardrail server using Quarkus gRPC — keeping the entire stack in Java. The guardrail server lives in part2-agentgateway/extmcp-guardrail/ and implements the agentgateway ExtMCP protocol.
First, the protobuf service definition (src/main/proto/extmcp.proto):
syntax = "proto3";
package agentgateway.dev.ext_mcp;
option java_package = "com.example.guardrail.grpc";
import "google/protobuf/struct.proto";
service ExtMcp {
rpc CheckRequest (McpRequest) returns (McpRequestResult);
rpc CheckResponse (McpResponse) returns (McpResponseResult);
}
message McpRequest {
repeated string service_names = 1;
string method = 2;
google.protobuf.Struct metadata_context = 3;
optional bytes mcp_request = 4;
repeated McpHeader headers = 5;
}
message McpRequestResult {
oneof result {
Pass pass = 1;
bytes mutated = 2;
AuthorizationError error = 3;
}
HeaderMutation header_mutation = 4;
}
message AuthorizationError {
enum Code { UNKNOWN = 0; PERMISSION_DENIED = 1; RESOURCE_EXHAUSTED = 2; INVALID = 3; }
Code code = 1;
string reason = 2;
optional bytes mcp_error = 3;
}
The Quarkus service implementation performs header sanitization and tool-poisoning detection:
@GrpcService
public class ExtMcpGuardrailService implements ExtMcp {
private static final Pattern DANGEROUS_HEADER = Pattern.compile(
"(?i)^(x-mcp-|x-forwarded-|x-real-ip)");
private static final List BLOCKED_PATTERNS = List.of(
"__proto__", "constructor", "../", "eval(", "exec(", "