FlyMCPRust
High-Performance MCP Server SDK for Rust
Built in Rust. Engineered for Speed.
FAST · SAFE · SIMPLE · STATELESS
Build Model Context Protocol servers with the ergonomics of FastMCP and the guarantees of Rust.
Status: version 0.1.1, a complete MCP server SDK for protocol 2026-07-28 (all 147 checks of the official conformance suite pass) that also serves 2025-11-25 and 2025-06-18 clients: tools, resources, prompts, completion, multi round-trip requests, progress and logging, MCP Apps interfaces, over stdio and Streamable HTTP, with OAuth 2.1 authorization on HTTP. On crates.io, with API documentation on docs.rs; see the changelog and the roadmap.
- Spec-first. Targets MCP revision 2026-07-28: stateless, with no
initializeand no sessions. Clients of 2025-11-25 and 2025-06-18 work too, without any change to your code. Protocol types are generated from the officialschema.json/schema.ts, and deprecations become#[deprecated]. - Ergonomic. Tools, resources and prompts are plain (async) functions.
Schemas, descriptions, arguments and annotations come from the signature
and doc comments. Asking the user for input is one
ctx.elicit(..).await?. - Safe. No
unsafe, no panics on the request path, and secure defaults: message size, JSON depth, handler timeout, concurrency, Origin/Host checks, localhost binding, signedrequestState. Internal errors are never exposed to clients. - Fast. Parsing borrows the request buffer, tool arguments deserialize directly into their struct, and list responses are pre-serialized.
- Composable. The server is a
tower::Service, and so is the HTTP transport, which mounts in axum.
Quick start
New to MCP or to this SDK? Follow From zero to an MCP server in Rust in 5 minutes.
[]
= { = "0.1", = ["stdio"] }
= { = "1", = ["macros", "rt-multi-thread"] }
use *;
/// Adds two numbers.
///
/// # Arguments
/// * `a` - First operand.
/// * `b` - Second operand.
async
async
What #[tool] gives you
| You write | You get |
|---|---|
| Doc comment (up to the first heading) | Tool description |
# Arguments list, or /// on a parameter |
Parameter descriptions in inputSchema |
Parameters (a: f64, tags: Vec<String>, limit: Option<u32>) |
inputSchema (JSON Schema 2020-12). Option means optional; unknown fields are rejected |
#[serde(..)] / #[schemars(..)] on a parameter |
Defaults, renames and validation ranges in the schema |
ctx: Ctx, db: State<Db> |
Injected request context and shared state, excluded from the schema |
-> Json<T> (or Result<Json<T>, E>) |
structuredContent plus an outputSchema derived from T |
-> Result<T, ToolError> with ToolError::user("…") |
isError: true with your message, so the model can self-correct |
-> Result<T, E: std::error::Error> |
Logged server-side; the client gets a generic message |
#[tool(read_only, destructive = false, idempotent, open_world)] |
annotations hints |
#[tool(name = "…", title = "…")] |
Name and title overrides |
Misuse is a compile error at the exact span: generics, self, borrowed
parameters, unknown arguments, invalid names, contradictory hints.
Register tools explicitly with .tool::<add>(). With the auto-register
feature, .auto_tools() collects every #[tool] in the binary.
Resources and prompts
/// The application configuration.
/// A user's profile.
async
/// Reviews a piece of code.
///
/// # Arguments
/// * `code` - The code to review.
/// * `language` - Its programming language.
- Templates use RFC 6570 (
{var},{+path},{/segment},{?query}). Variables become typed parameters (FromStr); a value that does not parse means the resource does not exist. - Prompt arguments are typed parameters too;
Option<T>is optional. - Completion comes from
#[complete(function)]or#[complete(values = [..])]on a prompt argument or template variable. CtxandState<T>work as in tools. Template mistakes, unknown variables and bad options are compile errors.
Asking the user for input
In 2026-07-28 the server cannot call the client mid-request. It answers
input_required, and the client retries with the answers (multi
round-trip requests). The SDK hides the round trips:
async
- The handler reruns from the start on each retry, and earlier answers come back immediately. Keep code before an input call free of side effects.
- Questions awaited together (
tokio::join!) go out in one round. - Answers from earlier rounds travel in
requestState, which is signed (HMAC-SHA256) and bound to the tool, its arguments and an expiry. Behind a load balancer, give every instance the same key with.request_state_key(..). - The client capability is checked first (
-32021if missing). Works in tools, resources and prompts.ctx.sample(..)andctx.list_roots(..)exist too, deprecated like the features themselves (SEP-2577).
Reporting progress
async
ctx.progressnever blocks and costs nothing when the client did not ask for progress. With a slow client, only the latest update is kept; it arrives before the result. Values must increase.- Over HTTP the response becomes an SSE stream only once there is something to stream; closing it cancels the tool.
ctx.log(level, data)sends log messages to clients that ask for them. It is deprecated like MCP logging itself (SEP-2577): log to stderr withtracinginstead.
Requiring OAuth (HTTP)
use JwtVerifier; // feature `oauth`
use ;
/// Deletes a file.
async
let resource = "https://mcp.example.com/mcp";
let verifier = builder.build?;
let oauth = builder
.authorization_server
.scopes_supported
.required_scopes
.build?;
let config = new
.allowed_hosts
.oauth;
let service = with_config;
- The server is an OAuth 2.1 resource server. Requests without a valid
bearer token get
401with aWWW-Authenticatechallenge that points to the Protected Resource Metadata document (RFC 9728), whichservepublishes at/.well-known/oauth-protected-resource/mcp. scopes = [..]on#[tool],#[resource]or#[prompt]: callers without every listed scope get403with aninsufficient_scopechallenge naming all of them..scope_implies(..)declares hierarchies such asfilesimplyingfiles:read.JwtVerifier(featureoauth) checks JWT access tokens: signature with the keys the authorization server publishes (found through its metadata, cached, refreshed on rotation), issuer, audience (this server), expiry. Only asymmetric algorithms exist in it (RSA, RSA-PSS, ECDSA, Ed25519), sononeand HMAC tokens are always refused. For opaque tokens, implementTokenVerifieryourself (a closureFn(&str) -> Result<Principal, TokenError>works); it must check that the token was issued for this server.requestStateand legacy sessions are bound to the user: a state or session obtained with one user's token is rejected with another's.- stdio is not authorized this way: per the specification, it takes credentials from its environment.
Interactive interfaces (MCP Apps)
/// The price survey, as an editable table.
/// Shows the price survey of an item.
/// Recomputes the reference price; only the table calls it.
appturns a fixedui://resource into an interface: an HTML5 page served astext/html;profile=mcp-app. Hosts render it in a sandboxed iframe that may load nothing from the network unless you list the origins (csp_connect,csp_resources,csp_frames,csp_base_uri); browserpermissionsandprefers_borderare also available.ui = ".."on a tool shows its results in that interface;visibility = ["app"]leaves a tool to the interface and hides it from the model in hosts that render interfaces; hosts without MCP Apps show it like any tool. It is not access control: protect sensitive tools with OAuth scopes.- The build fails if a tool names an interface that is not registered, or
if an origin is malformed. The extension is declared in
server/discover(and to 2025-11-25 hosts such as Claude Desktop) automatically. - Tools still return text: hosts without MCP Apps show it.
ctx.client_supports_apps()tells you whether the host renders the page. - A runnable example, tested in the official MCP Apps test host: examples/apps-demo.
Features
| Feature | Default | Description |
|---|---|---|
macros |
yes | The #[tool], #[resource] and #[prompt] attributes. |
stdio |
no | stdio transport. |
http |
no | Streamable HTTP transport (tower::Service plus a hyper server). |
oauth |
no | auth::JwtVerifier for OAuth access tokens (implies http; adds ring and rustls). |
auto-register |
no | auto_tools(), auto_resources() and auto_prompts() on the builder. |
logging |
no | logging::init(): the log, with fix-it hints, on stderr. |
test-client |
no | In-process testing::TestClient for integration tests. |
Protocol support (2026-07-28)
| Area | Status |
|---|---|
server/discover, per-request _meta, version negotiation (-32022) |
✅ |
tools/list (deterministic order, ttlMs/cacheScope), tools/call |
✅ |
resultType, serverInfo in every result's _meta |
✅ |
stdio: framing, concurrency, notifications/cancelled, graceful EOF |
✅ |
| Streamable HTTP: POST-only endpoint, 202/400/403/404/405/413/415 mapping | ✅ |
MCP-Protocol-Version / Mcp-Method / Mcp-Name validation (-32020), base64 sentinel |
✅ |
x-mcp-header → Mcp-Param-* validation (SEP-2243) |
✅ |
| Origin/Host allowlists (DNS-rebinding protection), CORS preflight | ✅ |
Resources and resource templates (resources/list, templates/list, read), caching hints |
✅ |
Prompts (prompts/list, prompts/get) |
✅ |
Completion (completion/complete) |
✅ |
Multi round-trip requests: elicitation, sampling, roots; signed requestState |
✅ |
Progress (notifications/progress), logging (notifications/message, deprecated), SSE response streaming |
✅ |
OAuth 2.1 authorization (resource server): bearer tokens, RFC 9728 metadata, 401/403 challenges, per-handler scopes |
✅ (built-in JWT verifier with the oauth feature, or bring a TokenVerifier) |
MCP Apps (io.modelcontextprotocol/ui): ui:// interfaces, CSP, tool visibility |
✅ |
| Subscriptions, Tasks extension, MCP client | Not implemented; see What is not implemented, and why |
2025-11-25 and 2025-06-18 clients: initialize, ping, signed sessions, ctx.elicit as server-to-client requests (stdio and SSE) |
✅ (on by default; legacy_clients(false) turns it off) |
What is not implemented, and why
The goal is the best SDK for building MCP servers: efficient, simple
and secure. Feature parity with the official
rmcp SDK is not a goal,
so features are added when real servers need them. Three are left out on
purpose; the architecture keeps room for each
(ARCHITECTURE §12).
- MCP client. A client is what uses servers: Claude, IDEs, agents. A
server author does not need one,
rmcpalready provides a good one, and duplicating it would double the code (and the attack surface) without helping anyone build servers. For tests there is the in-processtesting::TestClient. - Subscriptions (
subscriptions/listen, andresources/subscribefor 2025-11-25 clients). They keep one connection open per listening client, while this SDK is stateless: any instance answers any request, which is what makes it cheap to run and to scale horizontally. With several instances, an event published on one does not reach clients listening on another without a message bus. Few clients rely on them: most read a resource again, guided by thettlMsandcacheScopehints this SDK already sends. They are the only 2 failing checks of the 2025-11-25 conformance suite (76/78). - Tasks (the
io.modelcontextprotocol/tasksextension). The server keeps each task's state (progress, result, pending questions) for minutes or hours, and with several instances every request about a task must reach the instance that runs it. For long tools, the SDK already offers progress notifications, cancellation and questions to the user during the call (ctx.elicit), which cover most cases without server-side state.
They can be added later without rewriting the core: Tasks plugs into the extension mechanism, and subscriptions would reuse the SSE streaming path.
Official conformance suite
examples/conformance-server exposes the
fixtures of @modelcontextprotocol/conformance, and CI runs every 2026-07-28
server scenario against it. Known gaps are listed in
conformance-baseline.yml, and CI fails on any
other failure or on a stale entry.
Results with @modelcontextprotocol/conformance@0.2.0-alpha.12 on 2026-10-25:
all 147 checks of the 2026-07-28 suite pass, with no warnings.
| Scenario | Result |
|---|---|
server-stateless |
25/25 |
tools-list, tools-call-{simple-text,image,audio,embedded-resource,mixed-content,error} |
all pass |
resources-{list,read-text,read-binary,templates-read}, sep-2164-resource-not-found |
all pass |
prompts-{list,get-simple,get-with-args,get-embedded-resource,get-with-image} |
all pass |
completion-complete |
2/2 |
input-required-result-* (14 MRTR scenarios, including tampered state) |
all pass |
caching |
8/8 |
json-schema-2020-12 |
8/8 |
http-header-validation |
14/14 |
http-custom-header-server-validation |
10/10 |
dns-rebinding-protection |
2/2 |
tools-call-with-progress |
2/2 |
The 2025-11-25 suite (older clients) passes 76 of 78 checks; the 2
failures are resource subscriptions, which are not planned
(conformance-baseline-2025-11-25.yml).
Initialization, sessions, ping, logging, progress, tools, resources, prompts,
completion, elicitation and sampling (as server-to-client requests) and
DNS-rebinding protection all pass.
To reproduce locally, run cargo run -p conformance-server, then:
Try it with the MCP Inspector
The example server lives in examples/calculadora.
The MCP Inspector speaks 2025-11-25 by default (add --protocol-era modern
for 2026-07-28); the server answers both.
stdio, CLI:
Streamable HTTP: start the server, then point the Inspector at it.
Web UI: run npx @modelcontextprotocol/inspector. Add a server with
command target/debug/calculadora (stdio) or URL http://127.0.0.1:8000/mcp
(Streamable HTTP), set its protocol era to modern, and connect.
On Windows, use target\debug\calculadora.exe.
Testing your server
Enable test-client in [dev-dependencies] to drive a server in process
through the real wire format:
use TestClient;
async
Benchmarks
Against rmcp and the official Python SDK
bench/ runs the same server written with FlyMCPRust, rmcp 3.7.0
and the official Python SDK mcp 2.3.0 (MCP 2026-07-28, stateless), under
one load generator. Latest run: 2026-10-26, commit 47f1c60, GitHub Actions
4-core runners with the server and the load generator on separate CPUs,
median of 3 runs. Full tables: x86-64
(basic,
real-world) and
ARM64 (basic,
real-world).
Protocol overhead (three small tools), 64 concurrent clients, x86-64:
| Scenario | FlyMCPRust req/s | rmcp req/s | Python req/s | vs rmcp | vs Python |
|---|---|---|---|---|---|
HTTP tools/list |
86,552 | 33,184 | 2,223 | 2.6× | 39× |
HTTP tools/call (trivial tool) |
66,276 | 33,194 | 1,742 | 2.0× | 38× |
HTTP tools/call (structured output) |
44,799 | 23,848 | 1,556 | 1.9× | 29× |
HTTP tools/call (64 KiB in and out) |
7,653 | 8,546 | 896 | 0.9× | 8.5× |
stdio tools/list |
149,155 | 15,515 | 1,158 | 9.6× | 129× |
stdio tools/call (trivial tool) |
128,320 | 16,100 | 1,054 | 8.0× | 122× |
stdio tools/call (structured output) |
74,189 | 14,550 | 999 | 5.1× | 74× |
stdio tools/call (64 KiB in and out) |
4,532 | 3,020 | 738 | 1.5× | 6.1× |
On ARM64: 2.3–4.1× rmcp over HTTP and 3.0–6.2× over stdio on small messages; 64 KiB payloads 0.8× over HTTP and 1.8× over stdio.
Real-world scenarios: FlyMCPRust and rmcp serving the same 203 tools, x86-64 (ARM64 in brackets). "With JWT" means both servers verify an RS256 access token on every request with the same verifier:
| Scenario | vs rmcp | vs rmcp, both with JWT |
|---|---|---|
tools/list of 200 tools (~100 KB) |
6.0× [8.1×] | 3.3× [3.9×] |
| Calling the last of 200 tools | 2.5× [4.0×] | 1.2× [2.1×] |
| Nested ~10 KB order (40 lines, enums) | 3.4× [3.6×] | 2.4× [3.3×] |
| 1,024 / 4,096 keep-alive connections | 3.5× [5.6–5.8×] | 1.4–1.5× [2.6×] |
| A new connection per request | 1.2× [2.1×] | 1.4× [2.3×] |
| Tools waiting 20 ms on an HTTP upstream, 64 / 256 clients | 1.0× [1.0×], with 1.9–2.1× less CPU | 1.0× [1.1×] |
| Same, 1,024 clients | 2.3× [3.0×], p99 63 ms vs 218 ms | 1.5× [2.1×] |
| Mixed load (calls, orders, upstream, listing), 64 / 1,024 clients | 1.5× / 4.1× [1.5× / 4.2×] | 1.7× / 2.2× [1.7× / 3.0×] |
What the numbers say, including where FlyMCPRust does not lead:
- The gap grows with realistic servers: many tools, nested payloads and thousands of connections favor FlyMCPRust's pre-serialized lists and allocation-conscious dispatch. With 4,096 connections it uses 76 MiB against rmcp's 546 MiB.
- Waiting on other services evens throughput out: when tools spend 20 ms on an upstream, both SDKs serve the same requests per second at moderate concurrency; FlyMCPRust needs half the CPU, and pulls ahead only when concurrency gets high.
- Authentication narrows the gap, since verifying a signature costs the same in both servers.
- Large payloads over HTTP are slightly faster with rmcp (0.8–0.9×).
- FlyMCPRust 0.1.0's default limits were too conservative for servers
that wait on other services. 64 concurrent tool calls and 256 requests
in flight capped the server at about 3,000 requests per second with a
20 ms upstream, and at 1,024 clients it rejected most requests with
503("default limits" rows of the tables). Since 0.1.1 memory is bounded in bytes instead (64 MiB of request bodies at once), and the defaults allow 1024 concurrent calls and 4096 requests in flight. - Earlier runs overstated the lead. Our rmcp benchmark servers used
#[tool_router(server_handler)], which rebuilds the tool router on every request; they now keep it, as a production server would. The fix lowered FlyMCPRust's HTTP lead on small messages from 2.5–3.3× to 1.9–2.6× on x86-64. Earlier results are kept in bench/results/archive. - Peak memory in the real-world tables is the process's high-water mark and includes earlier workloads of the same run; compare it at the highest concurrency level.
These remain synthetic workloads on shared runners. Reproduce or challenge them with the commands in bench/README.md.
Micro-benchmarks
cargo bench -p flymcprust-core (criterion), measured on 2026-10-25 on an
Intel Core i5-12450HX laptop (16 GB, Windows 11) with Rust 1.99.1. Laptop
measurements vary by about ±20% between runs because of thermal throttling.
Each phase is checked against the previous code (interleaved runs, plus the
comparative suite): features such as multi round-trip requests, notifications
and legacy clients cost nothing on requests that do not use them.
| Benchmark | Median time | Notes |
|---|---|---|
parse/tools_call_add |
0.60 µs | JSON-RPC envelope plus limit checks (~300-byte message) |
parse/tools_list |
0.71 µs | |
parse/tools_call_64KiB |
9.7 µs | Including the depth pre-scan (48.9 µs before it skipped strings with memchr) |
dispatch/tools_list |
1.50 µs | Bytes in → bytes out, catalog of 32 tools |
dispatch/tools_call_add |
2.76 µs | Bytes in → bytes out: parse, _meta validation, argument deserialization, call, serialization |
dispatch/tools_call_64KiB |
53 µs | Echo of a 64 KiB string (115 µs before the memchr scan) |
serialize/call_result |
0.10 µs | Response envelope |
Security
Secure defaults are on without configuration: message, depth, time and
concurrency limits, localhost-only HTTP, panic isolation, sanitized errors,
signed MRTR state. docs/SECURITY.md has the threat model,
what your application must still do (authentication, security::confine_path
for files, idempotent code before ctx.elicit), and the review log. Report
vulnerabilities privately as described in SECURITY.md.
Development
See CLAUDE.md for conventions and docs/ARCHITECTURE.md for design decisions. Before each commit:
Fuzzing (nightly): run cargo install cargo-fuzz, then
cargo +nightly fuzz run parse_message from fuzz/.
License
Licensed under either of Apache License, Version 2.0 or MIT license, at your option.