rmcp-server-kit 1.3.2

Reusable MCP server framework with auth, RBAC, and Streamable HTTP transport (built on the rmcp SDK)
Documentation
//! Microbenchmark for [`HookedHandler`] overhead.
//!
//! Builds two scenarios on a Tokio runtime:
//!
//! - `hook_latency_bare`     - directly produces a small `CallToolResult`
//!   (the floor: no hooks, no spawn).
//! - `hook_latency_hooked`   - routes the same work through a no-op
//!   async before-hook (`HookOutcome::Continue`), then through the
//!   `Arc::clone` + `tokio::spawn` machinery used by the real after-hook
//!   path. (We can't invoke the private `apply_size_cap` from a bench;
//!   that step is exercised by unit tests in `src/tool_hooks.rs`.)
//!
//! The CI gate `bench-hook-overhead` runs both benches and asserts
//! `mean(hooked) - mean(bare) <= 2000 ns` via
//! `scripts/check-bench-overhead.{sh,ps1}`.
//!
//! ## Why an absolute-overhead gate (not a ratio)
//!
//! The plan-of-record (`.sisyphus/plans/0.12.0-implementation.md`,
//! H-A4) originally called for `mean(hooked) <= 1.05 * mean(bare)`.
//! Calibrating against the real numbers showed the bare baseline at
//! this measurement layer is ~300 ns (literally "return a struct"),
//! so any hook tax — even sub-microsecond — produces a multi-x ratio
//! while remaining negligible in practice (a real MCP request spends
//! tens of microseconds in transport+JSON before reaching a hook).
//! The honest gate is therefore on absolute overhead. 2 microseconds
//! comfortably accommodates the observed ~700 ns floor (one async
//! await + one `tokio::spawn` + Arc bookkeeping) while still catching
//! any regression that, say, accidentally reintroduced a blocking
//! `block_on` or a per-call allocation storm.
//!
//! We deliberately measure at the closure-invocation layer rather
//! than spinning up a full MCP server per iteration; that would drown
//! the hook overhead in transport noise and make the gate unable to
//! detect regressions in the hook machinery itself.

#![allow(
    clippy::expect_used,
    clippy::missing_docs_in_private_items,
    clippy::unreachable,
    missing_docs
)]

use std::{hint::black_box, sync::Arc};

use criterion::{Criterion, criterion_group, criterion_main};
use rmcp::model::{CallToolResult, Content};
use rmcp_server_kit::tool_hooks::{
    AfterHook, BeforeHook, HookDisposition, HookOutcome, ToolCallContext, ToolHooks,
};
use tokio::runtime::Builder;

fn make_ctx() -> ToolCallContext {
    ToolCallContext::for_tool("bench")
}

fn make_result() -> CallToolResult {
    CallToolResult::success(vec![Content::text("ok".to_owned())])
}

fn bench_hook_latency_bare(c: &mut Criterion) {
    let rt = Builder::new_current_thread()
        .enable_all()
        .build()
        .expect("build tokio runtime");

    c.bench_function("hook_latency_bare", |b| {
        b.iter(|| {
            rt.block_on(async {
                // Bare floor: an async block produces a CallToolResult,
                // exactly the inner-handler shape with no instrumentation.
                let r = async { make_result() }.await;
                black_box(r);
            });
        });
    });
}

fn bench_hook_latency_hooked(c: &mut Criterion) {
    // Multi-thread runtime so the spawned after-hook can actually run
    // concurrently with the iter loop and we measure real spawn cost,
    // not an artificially serialized current-thread spawn queue.
    let rt = Builder::new_multi_thread()
        .worker_threads(2)
        .enable_all()
        .build()
        .expect("build tokio runtime");

    let before: BeforeHook = Arc::new(|_ctx| Box::pin(async { HookOutcome::Continue }));
    let after: AfterHook = Arc::new(|_ctx, _disp, _bytes| Box::pin(async {}));

    let hooks = Arc::new(
        ToolHooks::new()
            .with_max_result_bytes(64 * 1024)
            .with_before(before)
            .with_after(after),
    );

    c.bench_function("hook_latency_hooked", |b| {
        b.iter(|| {
            rt.block_on(async {
                let ctx = make_ctx();
                // Mirror the call_tool branch order: before -> inner -> spawn after.
                if let Some(before) = hooks.before.as_ref() {
                    let outcome = before(&ctx).await;
                    if !matches!(outcome, HookOutcome::Continue) {
                        unreachable!("bench expects Continue");
                    }
                }
                let r = async { make_result() }.await;
                if let Some(after) = hooks.after.as_ref() {
                    let after = Arc::clone(after);
                    let ctx_clone = ctx.clone();
                    tokio::spawn(async move {
                        let fut = after(&ctx_clone, HookDisposition::InnerExecuted, 64);
                        fut.await;
                    });
                }
                black_box(r);
            });
        });
    });
}

criterion_group!(benches, bench_hook_latency_bare, bench_hook_latency_hooked);
criterion_main!(benches);