emperor-mcp 0.3.0

Opinionated MCP server framework: stateless Streamable HTTP transport, output framing, credential forwarding, rmcp bootstrap
Documentation

emperor-mcp

CI crates.io docs.rs license

The enterprise MCP server framework for Rust — Streamable HTTP first, credential forwarding, audited framing.

Built for teams putting Model Context Protocol servers into production: behind gateways, in front of load balancers, handling other people's credentials, feeding output to models that must not mistake data for instructions.

  • Deploy like a service, not a subprocess. Stateless Streamable HTTP: no session affinity, no Mcp-Session-Id, every request self-contained — load balancers and failover just work.
  • Handle credentials without becoming a secret store. Per-request header forwarding through an explicit allow-list; the server forwards and forgets.
  • Ship output models can't be hijacked through. Content-aware framing with nonce-guarded delimiters — indirect prompt injection mitigated at the source.

Every opinion is written down, versioned, and mapped to the spec section it narrows: the Emperor Profile (P1). A server built on emperor-mcp is a fully compliant MCP server — the profile narrows the spec's options, it never breaks them.

Five-minute quickstart

cargo new my-mcp-server && cd my-mcp-server
cargo add emperor-mcp serde_json
cargo add tokio --features full

src/main.rs — a complete MCP server with one framed tool:

use emperor_mcp::{FrameKind, McpHandler, frame, init_tracing, serve_http};
use serde_json::{Value, json};
use std::sync::Arc;

struct MyServer;

impl McpHandler for MyServer {
    async fn handle(&self, request: Value) -> Option<Value> {
        let id = request.get("id")?.clone(); // notifications get no reply
        let method = request.get("method").and_then(Value::as_str).unwrap_or_default();
        let result = match method {
            "initialize" => json!({
                "protocolVersion": "2025-06-18",
                "capabilities": { "tools": {} },
                "serverInfo": { "name": "my-mcp-server", "version": "0.1.0" }
            }),
            "tools/list" => json!({ "tools": [{
                "name": "echo",
                "description": "Echo text back as a framed computed result.",
                "inputSchema": { "type": "object",
                    "properties": { "text": { "type": "string" } }, "required": ["text"] }
            }]}),
            "tools/call" => {
                let text = request.pointer("/params/arguments/text")
                    .and_then(Value::as_str).unwrap_or("");
                json!({ "content": [{ "type": "text",
                    "text": frame(FrameKind::Computed, text) }], "isError": false })
            }
            _ => json!({}),
        };
        Some(json!({ "jsonrpc": "2.0", "id": id, "result": result }))
    }
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    init_tracing();
    serve_http("127.0.0.1:8017".parse().unwrap(), Arc::new(MyServer)).await
}
cargo run
# from another shell:
curl -s -X POST http://127.0.0.1:8017/ \
  -H 'Content-Type: application/json' -H 'Accept: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
# => {"id":1,"jsonrpc":"2.0","result":{"tools":[{"name":"echo", ...}]}}

Any MCP client speaking Streamable HTTP can now call your echo tool — and its output arrives framed as data, not instructions.

Why "emperor"?

Emperor penguins are the largest of all penguins — and the only ones that breed through the Antarctic winter, the harshest environment on Earth. They don't survive it by improvising. They survive it by protocol: the colony huddles in disciplined formation, every bird rotates through the exposed edge, nobody freelances, and the whole system works precisely because the rules are strict and everyone follows them.

That is exactly the stance this framework takes on running MCP servers in production. The spec deliberately leaves many choices open — transports, session handling, credential passing. Openness is right for a spec; in an enterprise deployment, every open choice is a place two systems disagree at 3 a.m. emperor-mcp closes those choices with one opinionated, fully spec-compliant profile — and like the huddle, none of it contradicts the wider colony.

The name is also a nod to this crate's origin: it hatched in the kowalski rookery, where the penguins run in hordes. Now it stands on its own ice.

What's inside

  • Transport — McpHandler + stateless Streamable HTTP + stdio (local/dev)
  • rmcp bootstrap — serve at /mcp + /health, graceful shutdown
  • Output framing — FrameKind / prompt-injection mitigation
  • Credential forwarding — ForwardedHeaders for multi-tenant deployments
  • Host / Origin validation — off behind a validating ingress, in-process with ServeOptions::with_allowed_hosts / with_allowed_origins
  • Credential rejection — credentials_rejected: an upstream 401/403 becomes the MCP response's HTTP status, so the client can ask the user to sign in again (a clear JSON-RPC error on stdio)
  • Limits — Limits: capped characters, results per call and download bytes, with hard ceilings; truncate_chars says what it left out
  • doctor — doctor::Report and generic checks for each server's doctor subcommand (listen address, env vars, TCP reachability, readable/writable directories, the 8-tool budget)
  • Optional features — cli: ServeArgs to #[command(flatten)] into a server's clap parser (--bind, --stdio, --allowed-host, --allowed-origin); testkit: assert_tools for a server's own tests (tool count, descriptions, annotations, object input schemas)

A tool that forwards the caller's credentials and gets refused upstream:

use emperor_mcp::{Rejection, credentials_rejected};

// inside a #[tool] returning Result<_, rmcp::model::ErrorData>
if let Some(rejection) = Rejection::from_status(upstream_status) {
    return Err(credentials_rejected(&ctx, rejection, "mail server")); // HTTP 401/403 to the client
}

Two server styles, one set of rules:

// rmcp path: #[tool_router] handlers, credential forwarding wired in
use emperor_mcp::{init_tracing, serve, ServeOptions};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    init_tracing();
    serve(ServeOptions::new("127.0.0.1:8080".parse()?), || MyServer::new()).await
}

For the hand-rolled McpHandler path, see the quickstart above or examples/minimal_server.rs. Production consumers: kowalski-mcp-rookery, kowalski-mcp-datafusion.

emperor-mcp vs plain rmcp

rmcp is the official Rust MCP SDK, and emperor-mcp is built on it — this is a layer, not an alternative.

You need Reach for
Full protocol breadth, custom transports, your own deployment opinions rmcp directly
A production posture out of the box: stateless HTTP, credential forwarding, framed output, a written profile to hold servers to emperor-mcp
A tiny hand-rolled server without SDK machinery emperor-mcp's McpHandler path

emperor-mcp pre-makes the deployment decisions and documents them in PROFILE.md; rmcp stays fully reachable underneath (the serve path takes any rmcp ServerHandler), so you can drop down whenever you need the SDK's full surface.

Authoring rules

  • PROFILE.md — the Emperor Profile (P1): the versioned deployment profile, rule by rule, with spec mapping and enforcement
  • MCP_REQUIREMENTS.md — authoring checklist for servers built on this framework
  • MANIFEST_SPEC.md — the manifest.yaml each server ships

Build

cargo test --all-features                   # unit + HTTP integration tests
cargo build --examples && ./scripts/smoke.sh  # example server round-trip

MSRV: Rust 1.88 (edition 2024), declared via rust-version in Cargo.toml.

Contributing

See CONTRIBUTING.md: run the checks, keep the docs in step, and sign off your commits (DCO, git commit -s).

License

Licensed under either of

at your option.

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in emperor-mcp by you shall be dual licensed as above, without any additional terms or conditions.