Rust MCP服务器运行时

一个用于快速构建生产级MCP(Model Context Protocol)服务器的轻量级Rust运行时,提供JSON-RPC 2.0协议、stdio与HTTP/SSE传输、Bearer认证、健康检查、CLI解析和结构化追踪等开箱即用的功能,适用于AI代理集成、编辑器扩展及服务间通信等场景。

  • by dravr-ai
  • Rust
  • 本地部署
  • 更新于
在 GitHub 查看 dravr-ai/dravr-tronc

使用说明

dravr-tronc

A lightweight Rust runtime for building MCP servers with optional REST API support. Provides everything you need to go from zero to a production-ready MCP server in minutes — JSON-RPC 2.0 protocol, stdio and HTTP transports, bearer auth, health checks, CLI parsing, and structured tracing.

Why dravr-tronc?

Building an MCP server in Rust means writing the same boilerplate every time: JSON-RPC 2.0 types, request dispatching, transport layers, auth middleware, CLI args. dravr-tronc extracts all of that into a single crate so you only write your domain logic.

  • Generic over state — McpServer<S> works with any Send + Sync state type
  • Two transports — stdio (for editor/CLI integration) and HTTP with SSE (for web clients)
  • Zero configuration — sensible defaults, env-var driven auth, plug and play
  • Production ready — constant-time auth, structured tracing, health checks, 600+ tests
  • Minimal dependencies — axum, tokio, serde, clap, tracing (no framework lock-in)

Quick start

[dependencies]
dravr-tronc = "6.3"

1. Define your state and tools

use std::sync::Arc;
use async_trait::async_trait;
use dravr_tronc::mcp::protocol::{CallToolResult, ToolDefinition};
use dravr_tronc::{McpTool, McpServer, ToolRegistry};
use serde_json::{json, Value};
use tokio::sync::RwLock;

struct AppState {
    greeting: String,
}

struct GreetTool;

#[async_trait]
impl McpTool<AppState> for GreetTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "greet".to_owned(),
            description: "Greet someone by name".to_owned(),
            input_schema: json!({
                "type": "object",
                "properties": {
                    "name": { "type": "string" }
                },
                "required": ["name"]
            }),
        }
    }

    async fn execute(&self, state: &Arc<RwLock<AppState>>, args: Value) -> CallToolResult {
        let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world");
        let greeting = state.read().await.greeting.clone();
        CallToolResult::text(format!("{greeting}, {name}!"))
    }
}

2. Wire it up

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // Held to the end of main: dropping it flushes OTLP export (feature `otel`).
    let _telemetry_guard = dravr_tronc::server::tracing_init::init("stdio");

    let mut registry = ToolRegistry::new();
    registry.register(Box::new(GreetTool));

    let state = Arc::new(RwLock::new(AppState {
        greeting: "Hello".to_owned(),
    }));
    let server = Arc::new(McpServer::new("my-mcp-server", "0.1.0", registry, state));

    // Serve over stdin/stdout (for Claude Desktop, Cursor, etc.)
    dravr_tronc::mcp::transport::stdio::run(server).await?;
    Ok(())
}

3. Or serve over HTTP

// Serve over HTTP with SSE support
dravr_tronc::mcp::transport::http::serve(server, "127.0.0.1", 3000).await?;

serve serves only /mcp, so the server's AuthHook is the only thing that can authenticate a request there. With no hook it serves loopback only: it resolves the host first and refuses — with TransportError::InsecureBind, before any socket opens — unless every address the host resolves to is loopback. To serve a reachable interface, attach a hook; for a shared key that is mcp::auth::ApiKeyAuthHook (see Startup posture).

A loopback bind also serves a loopback Host only (DNS-rebinding protection the Origin gate cannot give alone); name other authorities with McpServer::with_allowed_hosts. serve shuts down gracefully on SIGINT or SIGTERM, answering every request in flight first; serve_with_shutdown takes the trigger from the caller.

serve, serve_with_shutdown and stdio::run return mcp::transport::TransportError, so a binary can act on the cause — a Bind whose source is AddrInUse, a refused InsecureBind — instead of parsing a message. A main that only reports it ?s it into Box<dyn Error>.

What POST /mcp requires of a client (Streamable HTTP, revision 2026-07-28):

  • Content-Type: application/json (else 415); a body over McpServer::max_request_bytes (4 MiB by default) is a 413.
  • An Accept admitting application/json or text/event-stream (else 406). No Accept at all is */* (RFC 9110 §12.5.1), so curl, reqwest and k6 defaults are served. A single response is JSON unless text/event-stream weighs strictly more; a tie, */* and a missing header get JSON.
  • MCP-Protocol-Version on every modern request, naming the revision its _meta names; a request with neither is an initialize-era one.
  • The SEP-2243 mirrors: Mcp-Method on every modern request, Mcp-Name on tools/call, resources/read and prompts/get, and an Mcp-Param-{Name} for each argument a tool's input schema annotates with x-mcp-header. A mirror that disagrees with the body is a 400 HeaderMismatch (-32020) in any era.

A tool talks to its client while it runs through ctx.client (mcp::client_channel::ClientChannel): progress reports on a request that carried a progressToken; log sends notifications/message on a server that declares the logging capability, at or above the level the client asked for (logging/setLevel on its session, or the io.modelcontextprotocol/logLevel _meta key) and never when it asked for none; create_message and elicit (form mode, SEP-1034 defaults, the five SEP-1330 enum shapes) ask the client and wait for its answer. A call that sends anything before its response is answered with an event stream — each message an event, the response last — whenever the client's Accept admits text/event-stream (explicitly, through */*, or by sending none), and the client POSTs its answer to a server request back to /mcp (202). A client accepting only application/json gets the one JSON response: the call's progress and logs are dropped, and a server request fails with ClientRequestError::NoEventStream. A server request goes only to an initialize-era client that declared the capability, and fails with a typed ClientRequestError otherwise; revision 2026-07-28 carries none inside a call, so a tool there answers CallToolOutcome::InputRequired. Over stdio all of this is interleaved on the one connection.

McpServer::with_http_sessions(ttl) turns on Streamable HTTP sessions (2025-11-25): initialize mints an Mcp-Session-Id, a request naming a session that is not live — or is another caller's — is a 404, DELETE /mcp ends one, and one idle for ttl expires. They carry what an initialize-era client declares once (capabilities, log level), which is what lets a tool sample, elicit or log to it over HTTP. They are off by default because they live in process memory: turn them on only where a client's requests all reach one instance. A request without the header is served sessionless either way, and the stateless 2026-07-28 era never uses one. The store is bounded, since anyone reaching initialize starts a session: a session keeps the client's capabilities as flags, not the object it sent, and with_http_session_limits(total, per_caller) caps how many are held (10 000, and 1 000 per caller identity, by default). A caller at its limit gives up its least recently used idle session; a full server answers initialize with 503.

Tasks live in the host's TaskStore, so any instance answers tasks/get; the operation running a task lives in one process. A cancel, an expiry or a client's tasks/update taken by another instance reaches that operation through a host TaskSignalBus (TaskManager::with_signal_bus): the manager publishes a TaskSignal (serializable, for any wire), and the host's listener on each instance hands it to TaskManager::deliver. The carrier — Postgres LISTEN/NOTIFY, Redis pub/sub, a queue — is the host's, as the store is; the MCP spec does not say how a signal crosses processes. Without a bus, a cancel is still recorded in the store for the operation to read, and tasks/update for an operation elsewhere is refused as Detached.

notifications/cancelled reaches only the sender's own call: over stdio, the connection's; in a session, that session's; sessionless, the call of the same principal presenting the same bearer credential, so two clients of one user holding their own tokens are told apart. A sessionless caller the auth hook resolved no principal for — anonymous, or a shared API key — cannot be told from another, so its cancellation is ignored rather than allowed to reach a stranger's call. Any client cancels a call by closing its connection: a request dropped unanswered fires the token in ToolContext::cancellation, so work a tool runs outside the call stops too. For the notification itself, an initialize-era client can hold a session (with_http_sessions); a 2026-07-28 client, which never has one, needs an auth hook resolving a principal per client, each with its own credential.

tools/list pages when the server sets with_list_page_size, answering nextCursor; a host serving resources/list or prompts/list pages with mcp::pagination::paginate to issue the same cursors. A server given with_protected_resource_metadata has its RFC 9728 document served at /.well-known/oauth-protected-resource[/<resource path>]. A server known by several origins passes with_protected_resource_metadata_source a ProtectedResourceMetadataSet — one document per origin, the one a request dialed (X-Forwarded-Host, else Host) answered, the canonical one otherwise — or a closure choosing per request. Its auth hook challenges from the same document:

use dravr_tronc::mcp::resource_metadata::{ProtectedResourceMetadata, ProtectedResourceMetadataSet, SchemePolicy};

let policy = if require_https { SchemePolicy::RequireHttps } else { SchemePolicy::AllowHttp };
let doc = |origin: &str| -> Result<_, _> {
    Ok(ProtectedResourceMetadata::with_scheme_policy(origin, vec![issuer.clone()], policy)?
        .with_jwks_uri(format!("{issuer}/.well-known/jwks.json"))
        .with_scopes(scopes.clone()))
};
let set = ProtectedResourceMetadataSet::new(doc(&mcp_resource_url)?).with_alias(doc(&base_url)?);
server = server.with_protected_resource_metadata_source(set.clone());

// In the AuthHook: the challenge of the document the client fetched.
let challenge = set.for_host(request.dialed_host()).challenge();
let refused = challenge.clone().invalid_token().to_string();   // …, error="invalid_token"
let short = challenge.insufficient_scope("fitness:read").to_string(); // …, scope="fitness:read"

The router mounts the well-known route only when metadata is set, so a host that keeps serving the document from a router of its own (or mounts transport::http::resource_metadata_router) sets none on the server: both at once is an overlapping route, which panics when the routers merge.

4. Merge into an existing Axum app

use dravr_tronc::mcp::transport::http::mcp_router;
use dravr_tronc::server::request_guard::guard_requests;

let app = axum::Router::new()
    .route("/health", axum::routing::get(health_handler))
    .route("/api/data", axum::routing::get(data_handler))
    .merge(mcp_router(server))  // adds POST /mcp
    .layer(axum::middleware::from_fn(|req, next| {
        dravr_tronc::server::auth::require_auth("MY_API_KEY", req, next)
    }))
    .layer(axum::middleware::from_fn(guard_requests));  // outermost, once

serve in step 3 layers guard_requests itself; an app that assembles its own router adds it once, last. See Request guard.

Modules

Module Purpose
mcp::protocol JSON-RPC 2.0 types — requests, responses, errors, MCP initialize/tools/call
mcp::server Generic McpServer<S> — dispatches initialize, tools/list, tools/call, ping
mcp::tool McpTool<S> trait + ToolRegistry<S> — define and register tools; execute refuses ADMIN_ONLY to a non-admin, REQUIRES_AUTH/REQUIRES_TENANT to a context without a caller/tenant, and a tool's required_scopes to a grant missing one (one whose outputSchema root is not type: "object", or has a property schema that is not an object, is refused, logged at ERROR); ToolContext hands each call its caller and its request's _meta, typed (RequestMeta: progress token, modern per-request keys, extension keys)
mcp::observe Observer — passive start/complete hook around every dispatched message, with a typed OperationOutcome; PayloadCapturePolicy — tool payload capture, off by default, redacted by a host PayloadRedactor, truncated on a UTF-8 boundary
mcp::computation (feature computation) Computation — a tool stated as one typed operation: input and output schemas generated from its types, the result returned as structuredContent (and the same JSON as text) at each number's own precision; a list result is a schema::Listed<T> ({"items": [...]}), since structuredContent is an object
mcp::validation (feature schema-validation) ToolSchemaValidator — a tool's inputSchema/outputSchema compiled once (2020-12 by default, no remote $ref); ToolRegistry::execute refuses arguments and structured results that violate them with a tool error
mcp::tasks The tasks extension — TaskManager over a host TaskStore (atomic update), TaskRun (the operation's cancel token and input), TaskOptions; TaskSignalBus, the optional seam carrying tasks/cancel, TTL expiry and tasks/update input to an operation on another instance
mcp::client_channel ClientChannel (ToolContext::client) — a running call's progress, log messages, sampling and elicitation; ClientRequestError
mcp::logging LogLevel (RFC 5424 severities, ordered) and the notifications/message params
mcp::elicitation Form-mode elicitation/create — ElicitRequest, the restricted ElicitationSchema with SEP-1034 defaults and SEP-1330 enums, ElicitResult
testkit (feature testkit) McpTestClient — MCP over the in-process router (oneshot, no socket), the host's own router with its auth/CORS layers (over_router), or HTTP, with bearer, headers, _meta, legacy or modern era: initialize, list_tools, call_tool, request, exchange (the messages a call streams before its response), raw; it keeps the Mcp-Session-Id, answers server requests with an on_server_request handler, and end_sessions; McpTestServer — a server on 127.0.0.1:0; TestIssuer (with google-iam) — a stand-in authorization server minting caller tokens; testkit::assert — tool/JSON-RPC assertions and assert_tools_snapshot, a committed tools/list
mcp::transport::stdio Newline-delimited JSON over stdin/stdout; one connection, one session; on stdin EOF it answers the requests already read (10 s at most), then closes stdout and cancels the connection, so a job holding a tool's context does not hold the process
mcp::transport::http Axum POST /mcp handler with SSE (Streamable HTTP): event-stream answers, client responses, opt-in sessions and DELETE
mcp::transport::mirror SEP-2243 request headers — Mcp-Method, Mcp-Name, Mcp-Param-* held to the body
mcp::pagination Cursor pagination for list methods — opaque cursors that survive the list changing
mcp::resource_metadata ProtectedResourceMetadata — the RFC 9728 document the router can serve (jwks_uri optional, SchemePolicy for plain http); ProtectedResourceMetadataSet / ResourceMetadataSource — the document chosen per request, by dialed host; BearerChallenge — the 401/403 challenge with RFC 6750 error and scope
mcp::auth AuthHook seam — the host turns a request into a per-call ToolContext; ApiKeyAuthHook, the shared-key hook
mcp::caller (feature google-iam) Verified caller identity — CallerTokenVerifier checks a platform-minted RS256 JWT (JWKS signature, iss, per-server aud, exp/nbf) and reads sub/tenant/scope/client_id; CallerAuthHook makes it the call's ToolContext, optionally beside the shared key or anonymous loopback (both identity-free); CallerTokenConfig::from_env
server::auth Bearer token middleware — env-var driven, constant-time comparison; startup_auth / resolve_startup_auth, the startup posture check
http_client (feature http-client) describe_request_error — a reqwest::Error as text without its URL, which can carry a credential; guard — the SSRF-guarded fetch of a URL the operator did not choose: GuardedClient under a FetchPolicy (webhook: http(s), no redirects; allowlist: an operator's exact host list, e.g. a self-hosted LLM; archive_fetch: https, default port, exact hosts), every redirect hop re-checked up to 5, names resolved once and connected to only after every address passed the deny list (RFC 1918, loopback, link-local and metadata, 100.64/10, 0/8, 198.18/15, 240/4, multicast, ULA, 64:ff9b::/96, 2002::/16, Teredo, v4-mapped), no proxy, byte/idle/total caps, errors and url_digest without the URL; TracePropagationMiddleware (feature otel-http-client) — the reqwest-middleware 0.3 layer opening a CLIENT span per outbound request and sending its traceparent
iam (feature google-iam) Google ID tokens, both ends — IdTokenSource to call, require_google_id_token to be called; GoogleKeySet, the cache of Google's signing keys for one key-set URL, refetching for an unknown kid at most every 30s
service_client (feature service-client) ServiceClient — the client half of server::request_guard: ID-token auth (none on loopback), a fresh x-request-id per attempt, one re-send of a GET whose connection closed, shed and could-not-finish answers as typed errors
notifications::slack (feature notifications) Slack request-signature verification
notifications::ResendClient (feature notifications) The one Resend send path — alert and transactional mail — retrying a 429 within the advertised reset
server::request_guard Request ids, panic → JSON 500, per-router deadline → JSON 504, completion and dropped-request logs
server::shed shed_response — the 503 + Retry-After + retry_after_secs answer of a service that will not start a request
server::health HealthResponse builder with HTTP status codes
server::cli ServerArgs / McpArgs — clap structs for #[command(flatten)]
server::trace_context W3C traceparent / tracestate, read from HTTP headers and MCP params._meta; joined and propagated with feature otel
server::tracing_init Tracing subscriber — stderr for stdio, stdout for HTTP; returns the TelemetryGuard that flushes OTLP export on drop; init_with_ceilings holds per-target level ceilings over RUST_LOG
server::log_ceiling TargetCeiling / LevelCeilings — a filter AND-ed with EnvFilter that drops a target's spans and events above its ceiling, for a dependency whose TRACE output carries secrets
error ErrorResponse for REST APIs + JSON-RPC error code constants

Choosing an auth mechanism

There are six, and picking between them is the decision agents get wrong here — usually by reaching for the shared bearer key because it is the one with no feature flag. Pick by what the caller can present, and pick per route. A deployment changes; a caller's credential does not. Three of the six are behind cargo features, and default = [], so out of the box you have server::auth::require_auth and the mcp::auth::AuthHook seam and nothing else — that availability is not an argument for using them.

The caller is… Use Feature
another dravr workload on Google infrastructure iam::require_google_id_token (callee) / iam::IdTokenSource (caller) google-iam
a human, through a browser or an app the host's own session auth; mcp::auth::AuthHook for MCP routes —
a user, through a client the platform's authorization server issued a token to — on MCP routes of a server that holds no users of its own mcp::caller::CallerAuthHook over a CallerTokenVerifier google-iam
a third-party webhook that vendor's signature — e.g. notifications::slack::SlackClient::verify_signature — never a bearer notifications
our own binary, over the wire, with nowhere to put a Google identity server::auth::require_auth for REST routes, mcp::auth::ApiKeyAuthHook for MCP routes —
on stdio, or a linked crate in the same process nothing —

Mechanisms compose, so the question is never which one for a whole service. A server can verify Google ID tokens on its service-to-service routes, run an AuthHook on /mcp, and leave /health open, all at once.

Two rules regardless of which you pick:

  • /health stays unauthenticated. A liveness probe cannot present a credential; gating it breaks the deployment the moment the credential is provisioned.
  • Put the gate on a router, not on each route. mcp_routes.layer(from_fn(require_auth)) covers the next route someone adds; a per-route layer does not, and four services in this fleet shipped a middleware that guarded nothing because it was attached where no request reached it.

server::auth::require_auth fails open, deliberately

It reads the key from the environment variable you name, and when that variable is unset every request passes through. That is development mode, and it is load-bearing for stdio and local runs — two tests pin the behaviour so nobody "fixes" it into a refusal and breaks them.

// Enforced only when MY_API_KEY is set. Constant-time comparison via subtle::ConstantTimeEq.
dravr_tronc::server::auth::require_auth("MY_API_KEY", request, next).await

The cost is that a config slip turns a private service public and looks like a healthy boot. The startup posture check is how you refuse that instead.

Startup posture: refuse a reachable bind nothing gates

For a service gated by one shared key, startup_auth resolves the posture and logs it — INFO when the key is set, WARN when it is serving unauthenticated on loopback — and refuses a reachable bind without the key, naming the variable to set:

use dravr_tronc::server::auth::{startup_auth, AuthMode};
use dravr_tronc::mcp::auth::ApiKeyAuthHook;

let mode = startup_auth("MY_API_KEY", &args.host)?;

// MCP routes: the hook fails closed, so attach it only when the key is set.
let server = match mode {
    AuthMode::Enforced => server.with_auth_hook(Arc::new(ApiKeyAuthHook::new("MY_API_KEY", "my-service"))),
    AuthMode::LoopbackDev => server,
};

ApiKeyAuthHook answers a missing or wrong key with the MCP refusal — 401, WWW-Authenticate: Bearer realm="my-service" and a JSON-RPC error body — which a REST middleware layered over /mcp does not. It reads the key per request and admits the holder with auth_method = "api_key", never as an admin.

A service gated by something other than one key calls resolve_startup_auth with its own gate:

use dravr_tronc::server::auth::resolve_startup_auth;

// gated by Google ID tokens — no key exists to check; the middleware is the gate
let mode = resolve_startup_auth(&args.host, true)?;

Its second argument is whether anything at all authenticates requests on this bind — not whether a key is set. Read it as "is there a gate", and an identity-gated service that holds no key still binds 0.0.0.0, which every Cloud Run container must.

What counts as loopback. 127.0.0.0/8, ::1 (bracketed or not) and localhost in any case — RFC 6761 §6.3 reserves localhost for loopback, and browsers hard-wire it there. Everything else, 0.0.0.0 and :: included, is reachable, and so is any other name: the text check fails closed. A name is still only a name when it is bound, because the system resolver answers it — localhost with 127.0.0.1, ::1 or both, and on a misconfigured machine with anything. So the text check is a judgement made before any socket exists, and http::serve goes further: it judges every address the host resolves to before binding.

Verified caller identity: tools over one user's rows

A shared key says the caller holds the key, and nothing about which user or tenant is calling. A tool that reads or writes one user's rows in one tenant needs that identity on the credential, and must never take it as an argument — any key holder would then name any tenant.

mcp::caller (feature google-iam) verifies an RS256 JWT the platform's authorization server minted for this server: signature against the issuer's JWK set (cached by iam::GoogleKeySet), iss, aud equal to this server's own resource identifier (RFC 8707), exp, and nbf when present. sub becomes ToolContext::user_id, the tenant claim (active_tenant_id by default) tenant_id, the space-delimited scope claim scopes, and client_id client_id. Any failed check is a 401 carrying the challenge you configure — the reason goes to the log only — and a key set that cannot be fetched is a 500, because that outage says nothing about the token.

use dravr_tronc::mcp::auth::ApiKeyAuthHook;
use dravr_tronc::mcp::caller::{CallerAuthHook, CallerTokenConfig};
use dravr_tronc::server::auth::{api_key_configured, resolve_startup_auth, AuthMode};

// MY_RESOURCE_URL, MY_CALLER_ISSUER, MY_CALLER_JWKS_URL, MY_AUTHORIZATION_SERVER: all or none.
let caller = CallerTokenConfig::from_env("MY")?;
let keyed = api_key_configured("MY_API_KEY");
let mode = resolve_startup_auth(&args.host, keyed || caller.is_some())?;

if let Some(config) = caller {
    let metadata = config.resource_metadata()?;
    let mut hook = CallerAuthHook::new(Arc::new(config.verifier(http)), metadata.www_authenticate());
    if keyed {
        hook = hook.with_shared_key(ApiKeyAuthHook::new("MY_API_KEY", "my-service"));
    } else if mode == AuthMode::LoopbackDev {
        hook = hook.allowing_anonymous();
    }
    server = server.with_protected_resource_metadata(metadata).with_auth_hook(Arc::new(hook));
}

The tool states what it needs, and ToolRegistry::execute enforces it, so no tool stays closed by its own discipline:

  • ToolCapabilities::REQUIRES_AUTH — refused unless the context carries a user_id.
  • ToolCapabilities::REQUIRES_TENANT — refused unless it carries a user_id and a tenant_id.
  • McpTool::required_scopes — refused unless the grant holds every scope named, in the vocabulary of the authorization server that issues the tokens.

The shared key (with_shared_key) and an anonymous loopback request (allowing_anonymous) are admitted with no identity, so they keep reaching the tools that touch no tenant's data and never one that does. A request presenting a credential that fails is refused even when anonymous is allowed. testkit::TestIssuer stands in for the authorization server in tests: a fresh key, its JWK set on loopback, and a minter.

Request guard

A handler that panics, or that runs past a deadline, must answer the caller — never leave it with a connection that closed before any response, which it cannot tell apart from a network fault. server::request_guard holds the two middlewares that make that true:

use std::time::Duration;

use axum::middleware::from_fn;
use dravr_tronc::server::request_guard::{enforce_deadline, guard_requests};

let deadline = Duration::from_secs(300);
let app = rest_routes
    .layer(from_fn(move |req, next| enforce_deadline(deadline, req, next)))
    .merge(mcp_router(server))           // no deadline on /mcp
    .layer(from_fn(guard_requests));     // outermost, once
  • guard_requests gives every request an id — the caller's x-request-id when it is 1–128 characters of [A-Za-z0-9-_.:], a minted one otherwise — echoes it on the response, and puts it in the request's extensions as RequestId. It logs one INFO line per request (method, route template as path, status, latency_ms, request_id; never the concrete path or its query, which carry ids). A request that took at least the slow-request threshold completes with a WARN line instead, which adds threshold_ms: SLOW_REQUEST_THRESHOLD_MS sets it (default 10000, 0 turns it off, an unreadable value keeps the default), and guard_requests_with_threshold takes one from a server that configures itself elsewhere. A handler panic becomes a 500 with {"error":{"type":"handler_panic",…}} and an ERROR line carrying the panic payload. A request dropped before it answered — the client closed the connection mid-request, or the server went down — is logged at WARN with how long it ran. A POST answered with an event stream (an MCP call that reports progress or logs before its result) is still running while its body streams, so its line is written when the stream ends and latency_ms covers the whole call; a stream cut before its end is logged at WARN as cut, never as completed.
  • enforce_deadline answers a 504 with {"error":{"type":"request_timeout",…}} when the handler is still running at the deadline, and drops the handler. Put it on the router that holds one-shot REST routes, sized above the slowest legitimate request and below the caller's own client timeout, so the caller gets this answer instead of giving up first. Never on /mcp (a tool call is dispatched whole before it answers) or on a WebSocket or streamed response.

A contained panic needs panic = "unwind". Under panic = "abort" the first panic terminates the process and every in-flight request with it; no middleware can answer anything there.

Service client

service_client::ServiceClient (feature service-client) is the caller's side of the request guard: one service calling another over HTTP.

use std::time::Duration;
use dravr_tronc::service_client::{ServiceClient, ServiceError};

let client = ServiceClient::from_env("widgets", "WIDGETS_URL", "WIDGETS_AUDIENCE",
        Duration::from_secs(330))?
    .ok_or(MyError::NotConfigured)?;

let answer = client.get("list", "/api/widgets")
    .query(&[("limit", "5")])
    .header("x-session-id", session)
    .send().await?;
let page: WidgetPage = answer.json()?;

client.post_json("create", "/api/widgets", &widget).send().await?;
client.delete("drop", "/api/widgets/7").timeout(Duration::from_secs(10)).send().await?;
  • Auth. Every request carries a Google ID token addressed to the audience, minted by iam::IdTokenSource. A loopback base URL is called with no token, the mirror of the server's ungated loopback bind. A reachable URL with no audience is refused at construction, and so is one that is not https: the token is never sent in clear. A request path must start with /; one that does not is refused unsent, since appended to the base URL it could name another host. from_env answers Ok(None) only when the URL variable is unset or empty.
  • Request id. Each attempt is sent under a fresh x-request-id, which guard_requests logs and echoes. It is in Exchange on every response and every error but Identity.
  • Trace. With the otel feature, each attempt also carries the current span's W3C traceparent / tracestate, and guard_requests on the other side makes its request span a child of it: one trace spans both services.
  • Ok means the service's own handler answered, with any status: read status() and json_value() for the service's own refusals. Everything else is a ServiceError:
Error When
Identity no token could be minted; nothing was sent
Transport no response: TimedOut, Unreachable, ClosedBeforeResponse or Other
Shed a 503, or a non-2xx naming retry_after_secs — see server::shed::shed_response
Unfinished the guard's request_timeout 504 or handler_panic 500, or a gateway's 504 / 502
Body the status arrived and the body did not (a cut 503 / 504 / 502 is still Shed / Unfinished)
Decode json::<T>() on a body of another shape; the error says where and never quotes the body
  • One re-send. A get whose connection closed before any response is sent once more, under a new id; Exchange::resent says so. A post_json or delete is never re-sent, and neither is a timeout or an answered request.
  • Set the client timeout above the service's enforce_deadline, so an overrun arrives as the guard's 504 and not as the client's own timeout.

Observability

mcp::observe::Observer is a passive hook around every message McpServer dispatches, on every transport: start before dispatch with what the message says about itself (method, id, tool, protocol revision, session, _meta trace context), complete after with a typed OperationOutcome — Completed, ToolError (isError: true), TaskCreated, Failed (JSON-RPC code), NotificationAccepted, or Cancelled when the dispatch was dropped unanswered. The span start returns is the one dispatch runs in, so a tool handler's spans nest under it. No observer is installed by default, and an unobserved server does none of this work.

With the otel feature, OtelObserver records the OpenTelemetry MCP semantic conventions, and tracing_init exports its spans and metrics to OTEL_EXPORTER_OTLP_ENDPOINT. An exporter that fails to build is logged at WARN once the subscriber is up, naming which one failed. init returns a TelemetryGuard: hold it in main until the server has stopped, and its drop exports the last batch of spans and the last metric period before the process exits.

use dravr_tronc::mcp::observe::{OtelObserver, PayloadCapturePolicy, PayloadKind, PayloadSite};

let server = McpServer::new("my-server", "0.1.0", registry, state)
    .with_observer(Arc::new(OtelObserver::new()))
    .with_payload_capture(
        PayloadCapturePolicy::disabled()
            .with_request_arguments(true)
            .with_max_bytes(2048)
            .with_redactor(Arc::new(|site: &PayloadSite<'_>, mut args: Value| {
                args.as_object_mut()?.remove("athlete_id");
                Some(args)
            })),
    );
  • Span "{mcp.method.name} {target}" (tools/call get_sleep), kind SERVER, with mcp.method.name, jsonrpc.protocol.version, mcp.protocol.version, jsonrpc.request.id, mcp.session.id, and gen_ai.tool.name + gen_ai.operation.name=execute_tool (or gen_ai.prompt.name). A JSON-RPC error sets rpc.response.status_code and error.type to its code; a tool result with isError sets error.type=tool_error; both set an error status.
  • Histogram mcp.server.operation.duration (seconds), on the low-cardinality attributes only.
  • Trace context. A valid params._meta.traceparent parents the span; otherwise it is a child of the HTTP request span, which continues the traceparent header. ServiceClient writes the current context on every outbound call; any other reqwest client built with reqwest-middleware does it through http_client::TracePropagationMiddleware (feature otel-http-client), which opens an http.client.request CLIENT span per request (method and host only, never the URL), injects that span's context and records the response status. See server::trace_context.
  • Payloads (gen_ai.tool.call.arguments / .result) are captured only when a PayloadCapturePolicy asks for them. The host's PayloadRedactor runs on the JSON value first — returning None keeps nothing — and the text is cut to max_bytes (default 4096) on a UTF-8 boundary with a …(truncated) marker. Payload attributes go to OpenTelemetry directly, never into a log line.

Health checks

use dravr_tronc::server::health::HealthResponse;

let resp = HealthResponse::ok("my-service", "1.0.0")
    .with_detail("database", "connected")
    .with_detail("cache", "warm");
// Returns 200 for "ok", 503 for "degraded"

CLI args

Flatten shared args into your project's CLI struct:

use clap::Parser;
use dravr_tronc::server::cli::McpArgs;

#[derive(Parser)]
struct Cli {
    #[command(flatten)]
    server: McpArgs,  // adds --transport, --host, --port

    #[arg(long)]
    my_custom_flag: bool,
}

Conformance

CI runs the official MCP conformance suite against examples/conformance_server.rs — a plain McpServer with the suite's fixture tools — for both eras: suite 0.1.16 against spec 2025-11-25, and 0.2.0-alpha.11 against 2026-07-28. What the engine fails today is listed, per spec, in conformance/baseline-<spec>.yml; a new failure fails the job, and so does a listed scenario that starts passing, so a fix removes its line in the same change. Run it locally with bash scripts/ci/conformance.sh (needs npx).

Recommended project layout

my-project/
├── src/                          # Core domain library
├── crates/my-project-mcp/        # MCP server (library + binary)
│   ├── src/state.rs              # Your ServerState
│   ├── src/tools/                # Your McpTool<ServerState> implementations
│   └── src/main.rs               # Thin entry point using dravr-tronc
└── crates/my-project-server/     # REST API + MCP unified server (binary)
    ├── src/router.rs             # Axum routes + mcp_router() merge
    └── src/main.rs               # CLI + transport dispatch

License

Licensed under either of Apache License, Version 2.0 or MIT License at your option.