Architecture
Wanaku is a governed action proxy for AI agents, built in Rust on the Praxis proxy framework. It sits between agents and the systems they act on, intercepting every tool call, agent-to-agent message, and inference request. Policy, identity, data controls, and audit are enforced in the proxy — agents never touch backend systems directly.
Under the hood, Wanaku is an HTTP filter pipeline. Actions flow through a chain of filters, each responsible for a specific governance concern — identity, policy evaluation, namespace isolation, tool dispatch. Unlike a gateway that just passes traffic, the proxy does the work: it resolves tools, forwards actions to backends, and returns results to the agent.
High-Level Architecture
┌─────────────────────────────────────────────────────────────┐
│ LLM / AI Agent │
└────────────────┬────────────────────────────────────────────┘
│ MCP (JSON-RPC over HTTP)
▼
┌─────────────────────────────────────────────────────────────┐
│ Wanaku — Governed Action Proxy (Port 8081) │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Governance Pipeline (Praxis) │ │
│ │ CORS → MCP Parse → Namespace → Evaluator → │ │
│ │ Tool List/Call → Resource → Prompt → Static Response │ │
│ └────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ In-Memory Registry (DashMap) │ │
│ │ Tools │ Resources │ Prompts │ Forwards │ Namespaces │ │
│ └────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────┘
│
Action Forward (HTTP)
│
▼
┌───────────────────────┐
│ Backend Systems │
│ (Upstream MCP/Camel) │
└───────────────────────┘Action flow:
- Agent sends an MCP action (tool call, resource read, prompt get) to
/{namespace}/mcp - Governance pipeline intercepts: identity, policy, namespace isolation
- Filters query the in-memory registry for tools/resources/prompts
- For tool calls: the proxy forwards the action to the backend system registered as the tool's forward address — the agent never reaches the backend directly
- Response flows back through filters, wrapped in JSON-RPC
The Filter Pipeline
Filters are the heart of Praxis. Each request passes through a chain of filters defined in server/src/default.yaml:
filter_chains:
- name: mcp_router
filters:
- filter: cors # Add CORS headers
- filter: mcp # Parse JSON-RPC, set metadata
on_invalid: continue
- filter: wanaku_namespace # Extract namespace from path
- filter: wanaku_well_known # RFC 9728 OAuth metadata (feature)
- filter: wanaku_mcp_init # Initialize MCP context
- filter: wanaku_action_policy # Deterministic static action policy
- filter: wanaku_evaluator # Evaluator engine (feature)
- filter: wanaku_tool_list # Handle tools/list
- filter: wanaku_tool_call # Handle tools/call
- filter: wanaku_resource_list # Handle resources/list
- filter: wanaku_resource_read # Handle resources/read
- filter: wanaku_prompt_list # Handle prompts/list
- filter: wanaku_prompt_get # Handle prompts/get
- filter: static_response # Catch-all (404)Filter Execution Model
Filters implement the HttpFilter trait from praxis-filter. Each filter can hook into multiple phases:
on_request— called after headers are parsed, before body readon_request_body— called as body chunks arrive (or after full buffer in StreamBuffer mode)on_response— called before sending response to client
Most Wanaku filters use StreamBuffer mode for body access:
fn request_body_mode(&self) -> BodyMode {
BodyMode::StreamBuffer { max_bytes: Some(1_048_576) } // 1MB limit
}Praxis buffers the entire request body (up to the limit), then calls on_request_body once with the complete payload. This simplifies JSON-RPC parsing—you get the full message in one shot.
Critical Ordering: Why Namespace Runs in on_request_body
This is non-obvious and causes bugs if you get it wrong.
In StreamBuffer mode, Praxis executes filters in this order:
- Pre-read phase —
on_request_bodycalled for all filters, butbodyisNone(buffering not complete) - Post-read phase —
on_request_bodycalled again,bodyisSome(bytes)(buffer complete) - Request phase —
on_requestcalled for all filters
The MCP filter sets mcp.method metadata in its on_request_body handler during step 2. If the namespace filter ran in on_request during step 3, it would execute before the MCP filter's body handler. The metadata would not exist at that time.
Running both in on_request_body ensures they execute in pipeline order during the post-read phase.
Guard pattern:
async fn on_request_body(
&self,
ctx: &mut HttpFilterContext<'_>,
body: &mut Option<Bytes>,
end_of_stream: bool,
) -> Result<FilterAction, FilterError> {
if !end_of_stream {
return Ok(FilterAction::Continue); // Not ready yet
}
// Process body here
}Metadata Contract
Filters communicate via metadata keys set on the request context.
Set by MCP filter (praxis-ai):
mcp.method→"tools/list","tools/call","resources/read", etc.mcp.name→ tool/resource/prompt name (extracted fromparams.nameorparams.arguments)
Set by namespace filter:
wanaku.namespace→ extracted from URL path:/default/mcp→"default"/finance/mcp→"finance"
Wanaku rejects bare /mcp paths and nested namespace paths such as /nested/or/malformed/mcp.
Querying metadata:
let method = ctx.get_metadata("mcp.method")?;
let namespace = ctx.get_metadata("wanaku.namespace")?;All downstream filters (tool_list, tool_call, and others) use these keys. If a key is missing, the filter returns an error.
Why Custom Filters Instead of Praxis-AI's MCP Broker?
Wanaku uses the praxis-ai McpFilter classifier to parse JSON-RPC and set the mcp.method and mcp.name metadata. All downstream MCP handling uses custom Wanaku filters instead of the praxis-ai McpBrokerFilter. This includes tools/list, tools/call, resources/list, resources/read, prompts/list, and prompts/get. Wanaku uses custom filters for these reasons:
Praxis-ai's McpBrokerFilter is a static catalog broker. Tools are declared in YAML at deploy time with preconfigured backend clusters. In stateless mode it routes tools/call via Pingora's L7 proxy by matching tool names to cluster endpoints; in current mode tools/call is not even implemented. It has no concept of namespaces, dynamic discovery, or runtime registration.
Wanaku needs dynamic, namespace-aware routing:
| Capability | Praxis-ai Broker | Wanaku Filters |
|---|---|---|
| Tool catalog | Static YAML config | Dynamic InMemoryRegistry, populated at runtime via management API |
| Namespace isolation | None | Per-namespace filtering (/finance/mcp sees only finance tools) |
| Tool execution | L7 proxy forwarding to Pingora clusters (stateless profile only; unsupported in current profile) | MCP-to-MCP forwarding via rmcp crate |
| Forward discovery | None | Auto-discovers tools from upstream MCP servers on registration |
| Resources & Prompts | Not supported | Full resources/* and prompts/* filter support |
What we reuse from praxis-ai:
McpFilter(classifier) — JSON-RPC parsing, metadata extraction (mcp.method,mcp.name)- Praxis builtins —
cors,static_response,router,load_balancer
What is custom:
wanaku_namespace— path-based namespace extractionwanaku_mcp_init— handlesinitialize(capability negotiation),ping, andnotifications/initializedwanaku_tool_list/wanaku_tool_call— registry-backed tool routing with MCP forwardingwanaku_resource_list/wanaku_resource_read— registry-backed resource handlingwanaku_prompt_list/wanaku_prompt_get— registry-backed prompt handling- Feature filters (
wanaku_well_known,wanaku_evaluator) — registered by their respective feature crates, not by core filter registration
The Registry
The registry is the source of truth for tools, resources, prompts, namespaces, and forwards. InMemoryRegistry in infra/src/registry.rs implements this in-memory data structure.
Key design:
- Clone-safe: Uses
Arc<DashMap>internally, so cloning is cheap (bumps refcount) - Shared state: Injected into filter pipeline and management API via
PipelineExtension - Trait-based: Implements
ToolRegistry,ResourceRegistry,PromptRegistry,NamespaceRegistry,ForwardRegistry
Data structures:
pub struct InMemoryRegistry {
tools: Arc<DashMap<String, ToolEntry>>,
resources: Arc<DashMap<String, ResourceEntry>>,
prompts: Arc<DashMap<String, PromptEntry>>,
forwards: Arc<DashMap<String, ForwardEntry>>,
namespaces: Arc<DashMap<String, NamespaceEntry>>,
persistence: Option<Arc<dyn PersistenceBackend>>,
inject_request_id: Arc<AtomicBool>,
}Namespace isolation:
Tools, resources, and prompts all have a namespace field (defaults to "default"). When a filter queries the registry, it filters by namespace:
let tools = registry.list_tools()?;
let tools_in_namespace: Vec<_> = tools
.into_iter()
.filter(|t| t.namespace == namespace)
.collect();This is how /finance/mcp only sees tools registered in the "finance" namespace.
Persistence:
The registry operates in memory. File persistence is enabled by default and writes snapshots to $HOME/.wanaku/server/registry.json. Set a different path when necessary:
export WANAKU_PERSIST_BACKEND=file
export WANAKU_PERSIST_PATH=/data/registryOn startup, the server loads registry.json from WANAKU_PERSIST_PATH. On an orderly shutdown, it writes the current registry. Set WANAKU_PERSIST_BACKEND=none to disable persistence. File persistence supports one writer; it is not a shared production database.
Tool Routing
All tool execution in Wanaku happens via MCP forwarding. When a tool call arrives (tools/call), the tool_call filter:
- Looks up the tool in the registry by name
- Calls
mcp_client::call_tool(tool.uri, name, arguments)to forward the request to the upstream MCP server - Uses the
rmcpcrate to send an HTTP POST - Upstream returns MCP
CallToolResult - Filter wraps it in JSON-RPC and returns
Tools have type: "mcp-forward" and a uri pointing at the upstream MCP server. This is the only execution model — there is no built-in gRPC or local tool execution.
Forward discovery:
When you POST to /api/v1/forwards:
{
"name": "upstream-mcp",
"address": "http://upstream-server:8080/mcp"
}The management API:
- Registers the forward
- Calls
mcp_client::list_tools(address)to discover tools - Auto-registers each tool with
type: "mcp-forward"anduri: <forward.address>
Now when an LLM calls one of those tools, Wanaku forwards the request to the upstream server transparently.
Refreshing:
To re-discover tools after upstream changes:
curl -X POST http://localhost:8080/api/v1/forwards/upstream-mcp/refreshesThis removes all tools previously discovered from that forward and re-queries the upstream server.
The Feature System
Features are self-contained modules that extend Wanaku with new capabilities. They live in features/<name>/ and implement the Feature trait from types/src/feature.rs:
#[async_trait::async_trait]
pub trait Feature: Send + Sync {
fn name(&self) -> &'static str;
fn register_filters(&self, registry: &mut FilterRegistry);
fn pipeline_extensions(&self) -> Vec<Box<dyn PipelineExtension>>;
async fn handle_route(&self, ctx: &HttpContext<'_>) -> Option<Response<Vec<u8>>>;
fn load_yaml_config(&self, root: &serde_yaml::Value);
fn load_env_config(&self);
}Lifecycle:
- The server creates feature instances in
main.rs. - The server calls
load_yaml_configandload_env_config. - The server calls
register_filtersto add filters to the pipeline. - The server calls
pipeline_extensionsto get shared state, such as an LLM client. - For each request, the server calls
handle_routeuntil a feature owns the route.
Registered features (in main.rs):
- Metrics (
features/metrics/) — exposes an in-memory metrics snapshot - Intercept (
features/intercept/) — records request/response interactions for conversation history - MCP Metadata (
features/mcp-metadata/) — RFC 9728 OAuth Protected Resource Metadata and well-known endpoints - Action policy (
features/action-policy/) — deterministic allow and deny rules for MCP actions - Evaluator (
features/evaluator/) — WASM-based LLM evaluation engine for trigger→evaluate→act pipelines - Plugins (
features/plugins/) — loads external UI plugins from a filesystem directory
See Features for how to create your own.
Management API
The management API runs on port 8080 and uses Pingora's ServeHttp trait (not axum).
Request flow:
- Pingora calls
handle_requestinserver/src/management/mod.rs - Dispatcher tries core routes (tools, resources, prompts, namespaces, forwards)
- If no match, iterates over registered features and calls
feature.handle_route() - If still no match, returns 404
Response wrapper:
Management JSON responses generally use this format:
{"data": <payload>, "error": null} # success
{"data": null, "error": "message"} # errorThis matches the classic Wanaku API format for CLI compatibility.
Route pattern:
Core routes use a guard pattern defined in routes.rs:
pub(super) enum ToolRoute {
List,
GetByName(String),
Create,
Delete(String),
NotFound,
}
pub(super) fn resolve_tool_route(method: &str, path: &str) -> ToolRoute {
let suffix = match path.strip_prefix("/api/v1/tools") {
Some(s) => s,
None => return ToolRoute::NotFound,
};
let name = suffix.strip_prefix('/').filter(|s| !s.is_empty());
match (method, name) {
("GET", None) => ToolRoute::List,
("GET", Some(n)) => ToolRoute::GetByName(n.to_owned()),
("POST", None | Some("payloads")) => ToolRoute::Create,
("DELETE", Some(n)) => ToolRoute::Delete(n.to_owned()),
_ => ToolRoute::NotFound,
}
}Feature routes follow the same pattern but live entirely inside the feature crate.
Admin UI
The admin UI is a React 19 + TypeScript app built with Vite and embedded into the server binary via rust_embed. When you visit http://localhost:8080, the server serves static files from the embedded ui/admin/dist directory.
API integration:
The UI uses Orval to generate a TypeScript client from the OpenAPI spec (not yet implemented, currently hand-coded). All API calls go through src/api/wanaku-router-api.ts and use the customFetch wrapper for error handling.
Data access pattern:
const result = await getTools(); // Orval-generated function
const tools = result.data.data; // Unwrap: result.data (fetch wrapper) -> .data (server wrapper)See Contributing: Admin UI for development details.
Deployment Patterns
Standalone
Run Wanaku as a standalone proxy. Tools are registered via the management API or wanaku.yaml, and actions are forwarded to upstream servers.
Pros: Simple, no dependencies Cons: No persistence beyond file snapshots
Kubernetes
Deploy Wanaku as a Deployment with:
- Service: ClusterIP for MCP endpoint (port 8081)
- Service: LoadBalancer for management API (port 8080)
- ConfigMap:
wanaku.yamlbootstrap config - Secret: evaluator LLM connection credentials, mounted into
wanaku.yaml
Mount WANAKU_PERSIST_PATH to a PersistentVolume for registry persistence across restarts.
Performance Characteristics
Throughput:
Wanaku uses Pingora's async worker pool. Each worker handles requests concurrently. Throughput scales linearly with worker count (default: CPU core count).
Latency breakdown:
| Component | Typical Latency | Notes |
|---|---|---|
| Filter pipeline | ~1ms | CORS + MCP parse + namespace + tool lookup |
| MCP forward | ~20ms | HTTP roundtrip to upstream MCP server |
Memory:
Registry is in-memory. Each tool/resource/prompt is ~1KB. A deployment with 10,000 tools uses ~10MB RAM for the registry.
Security Model
Authentication:
Wanaku delegates authentication to oauth2-proxy, an external reverse proxy that sits in front of the MCP and management API ports. Wanaku itself contains zero authentication code.
oauth2-proxy sidecar pattern:
Two oauth2-proxy instances with a shared cookie provide SSO across both endpoints:
- oauth2-proxy-mcp (port 4180 → 8081) — protects MCP endpoints, requires
mcp-userrole - oauth2-proxy-mgmt (port 4181 → 8080) — protects admin UI and REST API, requires
adminrole
Users authenticate via oauth2-proxy's browser-based login flow (PKCE). CLI clients obtain tokens from Keycloak and pass them as Authorization: Bearer <token> headers — oauth2-proxy validates them before proxying to Wanaku.
Wanaku-side metadata:
The features/mcp-metadata/ crate exposes RFC 9728 OAuth Protected Resource Metadata at /.well-known/oauth-protected-resource/{namespace}/mcp. This read-only endpoint returns the OIDC issuer URL configured through WANAKU_AUTH_ISSUER.
When auth is disabled:
- Run Wanaku standalone on ports 8081/8080 without oauth2-proxy
- No authentication on either endpoint
CORS:
CORS is enabled by default via the cors filter (allows all origins). Restrict origins in server/src/default.yaml:
- filter: cors
allow_origins: ["https://app.example.com"]What's Not Here (Yet)
- Persistence beyond file snapshots — no PostgreSQL/Redis integration
- Multi-tenancy — namespaces provide isolation, but no user/tenant association
- Rate limiting — no throttling on MCP or management API
- External metrics export — Wanaku collects in-memory metrics, but it does not provide a Prometheus exporter or distributed tracing integration
- Clustering — single-node only, no distributed registry
These are all solvable (implement traits, add filters), but they're not in scope for the initial release.
Related Docs
- Configuration — all env vars and YAML options
- Action Policies — schema, matching, decisions, and revisions
- Features — enable evaluators, create custom features
- Management API — REST API reference
- Contributing: Admin UI — customize the embedded UI