Build a feature crate
This tutorial builds a small feature crate. The feature counts MCP tool calls and shows the count through the management API. At the end, the page shows patterns that most feature crates use.
Prerequisites
- A clone of the Wanaku repository.
- A Rust toolchain that matches the MSRV in
Cargo.toml. - Knowledge of the
Featuretrait. Read Feature crates first.
The procedure creates the crate in features/tool-stats.
Step 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 = trueStep 2: 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) {}
}Step 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)
}
}Step 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 crate to the cache-build stage of Containerfile. Otherwise, cargo build-image fails. The stage has three lists. Add one entry to each list:
COPY features/tool-stats/Cargo.toml features/tool-stats/Cargo.toml
# In the RUN mkdir -p list:
features/tool-stats/src
# In the stub list:
&& echo '//! stub' > features/tool-stats/src/lib.rs \Add the feature to the vector returned by build_features in server/src/app.rs:
let features: Vec<Box<dyn Feature>> = vec![
// ... existing features ...
Box::new(wanaku_feature_tool_stats::ToolStatsFeature::new()),
];Add the filter to the mcp_router chain in server/src/default.yaml, after wanaku_namespace and before wanaku_well_known:
- filter: wanaku_namespace
- filter: wanaku_tool_stats
- filter: wanaku_well_knownThe filter must run before wanaku_action_policy and wanaku_evaluator. A rejection in those filters skips all filters after them. With the default governance posture (no_match: deny), the action policy rejects a call that no rule allows. A filter after it does not see that call.
default.yaml is embedded with include_str!. Touch server/src/lib.rs after you edit it, so that cargo build picks up the change.
Step 5: Test it
cargo build
cargo runCall a tool. The tool does not need to exist. The filter counts each tools/call request:
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}The tool call itself returns a JSON-RPC error. On a server without forwards and with the default posture, the action policy denies it with -32003. The count still increases, because the new filter runs first.
Feature patterns
Share state with filters
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>>();Reload configuration at runtime
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();Call an LLM
Use LlmClient from wanaku_infra::llm for OpenAI-compatible LLM calls:
use wanaku_infra::llm::LlmClient;
// `new` returns `None` when the HTTP client cannot be built.
let Some(client) = LlmClient::new("http://localhost:11434/v1", "llama3.1:8b", "") else {
return Err(MyError::LlmClient);
};
// `chat` returns `Result<String, LlmError>`.
let response = client.chat("You are a concise assistant.", user_prompt).await?;LlmClient appends /chat/completions to the base URL. See features/evaluator/src/engines/llm.rs for a full example.
Build management API responses
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");Next steps
- Feature crates: read the feature lifecycle and the startup rules.
- Architecture: see where your filter runs in the pipeline.
- Management API: follow the same route and response patterns.
- Testing: run the test suites before you open a pull request.