使用说明
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 anySend + Syncstate 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 overMcpServer::max_request_bytes(4 MiB by default) is a 413.- An
Acceptadmittingapplication/jsonortext/event-stream(else 406). NoAcceptat all is*/*(RFC 9110 §12.5.1), socurl, reqwest and k6 defaults are served. A single response is JSON unlesstext/event-streamweighs strictly more; a tie,*/*and a missing header get JSON. MCP-Protocol-Versionon every modern request, naming the revision its_metanames; a request with neither is aninitialize-era one.- The SEP-2243 mirrors:
Mcp-Methodon every modern request,Mcp-Nameontools/call,resources/readandprompts/get, and anMcp-Param-{Name}for each argument a tool's input schema annotates withx-mcp-header. A mirror that disagrees with the body is a 400HeaderMismatch(-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:
/healthstays 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 auser_id.ToolCapabilities::REQUIRES_TENANT— refused unless it carries auser_idand atenant_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_requestsgives every request an id — the caller'sx-request-idwhen 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 asRequestId. It logs one INFO line per request (method, route template aspath,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 addsthreshold_ms:SLOW_REQUEST_THRESHOLD_MSsets it (default 10000,0turns it off, an unreadable value keeps the default), andguard_requests_with_thresholdtakes one from a server that configures itself elsewhere. A handler panic becomes a500with{"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. APOSTanswered 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 andlatency_mscovers the whole call; a stream cut before its end is logged at WARN as cut, never as completed.enforce_deadlineanswers a504with{"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 nothttps: 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_envanswersOk(None)only when the URL variable is unset or empty. - Request id. Each attempt is sent under a fresh
x-request-id, whichguard_requestslogs and echoes. It is inExchangeon every response and every error butIdentity. - Trace. With the
otelfeature, each attempt also carries the current span's W3Ctraceparent/tracestate, andguard_requestson the other side makes itsrequestspan a child of it: one trace spans both services. Okmeans the service's own handler answered, with any status: readstatus()andjson_value()for the service's own refusals. Everything else is aServiceError:
| 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
getwhose connection closed before any response is sent once more, under a new id;Exchange::resentsays so. Apost_jsonordeleteis 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's504and 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), kindSERVER, withmcp.method.name,jsonrpc.protocol.version,mcp.protocol.version,jsonrpc.request.id,mcp.session.id, andgen_ai.tool.name+gen_ai.operation.name=execute_tool(orgen_ai.prompt.name). A JSON-RPC error setsrpc.response.status_codeanderror.typeto its code; a tool result withisErrorsetserror.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.traceparentparents the span; otherwise it is a child of the HTTPrequestspan, which continues thetraceparentheader.ServiceClientwrites the current context on every outbound call; any otherreqwestclient built withreqwest-middlewaredoes it throughhttp_client::TracePropagationMiddleware(featureotel-http-client), which opens anhttp.client.requestCLIENT span per request (method and host only, never the URL), injects that span's context and records the response status. Seeserver::trace_context. - Payloads (
gen_ai.tool.call.arguments/.result) are captured only when aPayloadCapturePolicyasks for them. The host'sPayloadRedactorruns on the JSON value first — returningNonekeeps nothing — and the text is cut tomax_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.
