Features
Features are self-contained Rust crates that extend Wanaku. A feature can register pipeline filters, expose management API routes, and manage state.
Think of features as plugins. The core server provides the infrastructure (filter pipeline, registry, management API), and features build on top of it.
Built-in Features
Wanaku ships with six features:
Metrics Feature (features/metrics/)
Collects in-memory filter, evaluator, LLM, and WASM metrics.
Management API route: GET /api/v1/metrics
Intercept Feature (features/intercept/)
Records MCP request/response interactions in an in-memory store. Other features — especially the evaluator engine — use this history to provide conversation context to LLM operations.
Filter: wanaku_intercept (runs in the inference proxy pipeline)
Management API routes:
GET /api/v1/interactions— list recorded interactionsDELETE /api/v1/interactions— clear the interaction store
Configuration:
| Variable | Default | Purpose |
|---|---|---|
WANAKU_INTERACTION_CAPACITY | 1000 | Maximum interactions kept in memory |
MCP Metadata Feature (features/mcp-metadata/)
Exposes RFC 9728 OAuth Protected Resource Metadata to advertise authentication requirements to MCP clients. This is a read-only metadata endpoint — actual authentication is handled by oauth2-proxy as an external sidecar.
How it works:
- MCP client queries
GET /.well-known/oauth-protected-resource/{namespace}/mcp - Wanaku returns JSON metadata with the OIDC issuer URL configured via
WANAKU_AUTH_ISSUER - MCP client uses the issuer URL to discover the authorization server and token endpoints
Configuration:
Set the OIDC issuer URL:
export WANAKU_AUTH_ISSUER=http://localhost:8543/realms/wanakuIf WANAKU_AUTH_ISSUER is unset, the metadata endpoint returns a 404 (feature disabled).
Example response:
{
"issuer": "http://localhost:8543/realms/wanaku",
"authorization_endpoint": "http://localhost:8543/realms/wanaku/protocol/openid-connect/auth",
"token_endpoint": "http://localhost:8543/realms/wanaku/protocol/openid-connect/token"
}Authentication architecture:
Wanaku does not validate tokens or enforce authentication. oauth2-proxy provides these controls. It runs as a reverse proxy in front of the MCP port (8081) and management API port (8080).
For deployment details, see deploy/auth/README.md and Configuration.
Evaluator Feature (features/evaluator/)
A WASM-based evaluation engine that lets you build trigger→evaluate→act pipelines. When an MCP request matches a trigger, the engine calls an LLM to classify or filter, then runs a WebAssembly action script that can block requests, filter tool lists, or set metadata.
Filter: wanaku_evaluator
Management API routes:
GET /api/v1/evaluators— list configured evaluatorsPUT /api/v1/evaluators— hot-reload evaluator configurationGET /api/v1/evaluators/namespaces— list namespace-to-conversation bindingsPUT /api/v1/evaluators/namespaces/{namespace}— bind a namespace to a conversation IDDELETE /api/v1/evaluators/namespaces/{namespace}— unbind
See the Evaluator Engine guide for configuration details and WASM action script development.
Plugins Feature (features/plugins/)
Loads external UI plugins from a filesystem directory. Plugins are ES modules that can add navigation entries, pages, and backend service integrations to the admin UI at runtime.
Configuration:
Start the server with --plugins-path:
cargo run -- --plugins-path /path/to/pluginsSee the Plugin Development Guide for details.
Creating a Custom Feature
The following procedure creates a feature that counts tool calls and exposes the count through the management API.
1. Create the Crate
cd features
mkdir -p tool-stats/src
cd tool-statsCreate Cargo.toml:
[package]
name = "wanaku-feature-tool-stats"
version = "0.1.0"
edition = "2024"
[dependencies]
wanaku-types = { workspace = true }
wanaku-infra = { workspace = true } # required by wanaku-filters' body_filter_boilerplate! macro
wanaku-filters = { workspace = true }
praxis-filter = { workspace = true }
async-trait = { workspace = true }
http = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
bytes = { workspace = true }
tracing = { workspace = true }
[lints]
workspace = true2. Implement the Feature Trait
Create src/lib.rs:
use wanaku_types::feature::{Feature, HttpContext};
use praxis_filter::{FilterRegistry, PipelineExtension, RequestExtensions};
use http::Response;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
pub struct ToolStatsFeature {
counter: Arc<AtomicU64>,
}
struct ToolStatsExtension {
counter: Arc<AtomicU64>,
}
impl PipelineExtension for ToolStatsExtension {
fn prepare(&self, extensions: &mut RequestExtensions) {
extensions.insert(self.counter.clone());
}
}
impl ToolStatsFeature {
pub fn new() -> Self {
Self {
counter: Arc::new(AtomicU64::new(0)),
}
}
}
impl Default for ToolStatsFeature {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl Feature for ToolStatsFeature {
fn name(&self) -> &'static str {
"tool-stats"
}
fn register_filters(&self, registry: &mut FilterRegistry) {
praxis_filter::register_filters!(
@register registry,
http "wanaku_tool_stats" => ToolStatsFilter::from_config
);
}
fn pipeline_extensions(&self) -> Vec<Box<dyn PipelineExtension>> {
vec![Box::new(ToolStatsExtension {
counter: self.counter.clone(),
})]
}
async fn handle_route(&self, ctx: &HttpContext<'_>) -> Option<Response<Vec<u8>>> {
if ctx.method == "GET" && ctx.path == "/api/v1/stats/tools" {
let count = self.counter.load(Ordering::Relaxed);
let body = serde_json::json!({"tool_calls": count});
Some(wanaku_types::http_response::json_ok(&body))
} else {
None
}
}
fn load_yaml_config(&self, _root: &serde_yaml::Value) {}
fn load_env_config(&self) {}
}3. Implement the Filter
Add to src/lib.rs:
use wanaku_filters::body_filter_boilerplate;
use praxis_filter::{HttpFilterContext, FilterAction, FilterError};
use bytes::Bytes;
body_filter_boilerplate!(ToolStatsFilter, "wanaku_tool_stats");
impl ToolStatsFilter {
async fn handle_body(
&self,
ctx: &mut HttpFilterContext<'_>,
_body: &mut Option<Bytes>,
) -> Result<FilterAction, FilterError> {
if ctx.get_metadata("mcp.method") != Some("tools/call") {
return Ok(FilterAction::Continue);
}
let Some(counter) = ctx.extensions.get::<Arc<AtomicU64>>() else {
return Ok(FilterAction::Continue);
};
counter.fetch_add(1, Ordering::Relaxed);
tracing::info!("Tool call count: {}", counter.load(Ordering::Relaxed));
Ok(FilterAction::Continue)
}
}4. Wire It Into the Server
Add to workspace Cargo.toml:
[workspace]
members = [
# ... existing members ...
"features/tool-stats",
]
[workspace.dependencies]
wanaku-feature-tool-stats = { path = "features/tool-stats" }Add to server/Cargo.toml:
[dependencies]
wanaku-feature-tool-stats = { workspace = true }Add the feature to the vector returned by build_features in server/src/main.rs:
let features: Vec<Box<dyn Feature>> = vec![
// ... existing features ...
Box::new(wanaku_feature_tool_stats::ToolStatsFeature::new()),
];Add filter to server/src/default.yaml (after wanaku_evaluator, before wanaku_tool_list):
- filter: wanaku_tool_stats5. Test It
cargo build
cargo runCall a tool:
curl -X POST http://localhost:8081/default/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "echo", "arguments": {"message": "test"}}, "id": 1}'Check stats:
curl http://localhost:8080/api/v1/stats/toolsExpected response:
{"data": {"tool_calls": 1}, "error": null}Feature Patterns
State Management
Features can share state between filters and management API handlers via pipeline_extensions. Implement PipelineExtension on a wrapper type:
struct CounterExtension {
counter: Arc<AtomicU64>,
}
impl PipelineExtension for CounterExtension {
fn prepare(&self, extensions: &mut RequestExtensions) {
extensions.insert(self.counter.clone());
}
}
fn pipeline_extensions(&self) -> Vec<Box<dyn PipelineExtension>> {
vec![Box::new(CounterExtension { counter: self.counter.clone() })]
}Filters retrieve the state via:
let counter = ctx.extensions.get::<Arc<AtomicU64>>();Hot-Reloadable Config
Use HotSwap<T> from wanaku_infra::llm for runtime-reconfigurable state:
use wanaku_infra::llm::HotSwap;
pub struct MyFeature {
config: HotSwap<MyConfig>,
}
// In handle_route:
if ctx.path == "/api/v1/myfeature/config" && ctx.method == "PUT" {
let body = ctx.body.unwrap_or("");
let new_config: MyConfig = serde_json::from_str(body).ok()?;
self.config.set(new_config);
}Filters read the config via:
let config = feature_state.config.get();LLM Integration
Use LlmClient from wanaku_infra::llm for OpenAI-compatible LLM calls:
use wanaku_infra::llm::LlmClient;
let client = LlmClient::new("http://localhost:11434/v1", "llama3.1:8b", "")?;
let response = client.chat("You are a concise assistant.", user_prompt).await?;See features/evaluator/src/llm_op.rs for a full example.
Management API Response Helpers
Use the response helpers from wanaku_types:
use http::StatusCode;
use wanaku_types::http_response::{json_err, json_ok};
let response = json_ok(&serde_json::json!({"status": "ready"}));
let error = json_err(StatusCode::BAD_REQUEST, "invalid configuration");Feature Discovery
The server does not scan for features. List each feature explicitly in main.rs:
let features: Vec<Box<dyn Feature>> = vec![
// ... existing features ...
Box::new(MyFeature::new()),
];The explicit list makes feature registration visible. Read main.rs to identify the enabled features.
Feature Lifecycle
- Creation: The server constructs each feature in
main.rs. - Config loading: The server calls
load_yaml_config()andload_env_config(). - Filter registration: The server calls
register_filters()to add filters to the pipeline. - Extension injection: The server calls
pipeline_extensions()to get shared state. - Request handling: The server calls
handle_route()for each feature until a feature owns the route.
The Feature trait does not provide a shutdown hook. Implement Drop when the feature must release resources.
Disabling Features
To disable a feature:
- Remove the feature from the
featuresvector inmain.rs. - Remove its filter from
server/src/default.yaml. - Rebuild the server.
No env var or config flag. Features are compile-time dependencies.
Related Docs
- Architecture — how features fit into the filter pipeline
- Configuration — feature-specific env vars
- Management API — feature routes follow the same patterns