# Architecture
This document captures every architectural decision needed to reproduce `mcpserver` from scratch. It is written for both humans and LLMs.
## Core principle
**The library is a pure MCP protocol handler.** It has zero HTTP, transport, or runtime opinion. The single entry point is:
```rust
pub async fn handle(&self, req: JsonRpcRequest) -> McpResponse
```
The application owns: listening on a port, routing, middleware (auth, rate limiting), HTTP status codes, session management, and TLS. The library owns: JSON-RPC 2.0 parsing, MCP method routing, schema validation, handler dispatch, and response construction.
This means the library's dependency footprint is minimal — `serde`, `serde_json`, `async-trait`, `tracing`, `thiserror`. No `axum`, `tokio`, `hyper`, or any HTTP crate.
## Module layout
```
src/
lib.rs — Module declarations and public re-exports
types.rs — All type definitions, McpResponse, serialization
server.rs — Server struct, builder, handler traits, MCP routing
loader.rs — JSON file/bytes → Vec<Tool> / Vec<Resource>
validate.rs — Tool::validate_arguments() against SchemaMeta
```
### `lib.rs`
Only module declarations (`pub mod ...` / `mod ...`) and `pub use` re-exports. The `validate` module is `mod validate` (not `pub mod`) because `Tool::validate_arguments()` is a public method on a public type — no need to expose the module itself.
Re-exports are the public API surface:
```rust
pub use server::{FnToolHandler, ResourceHandler, Server, ServerBuilder, ToolHandler};
pub use types::{
error_result, new_error_response, text_result, ContentBlock, JsonRpcRequest, JsonRpcResponse,
McpError, McpResponse, Resource, ResourceContent, RpcError, Tool, ToolResult, PROTOCOL_VERSION,
};
pub use loader::{load_resources, load_tools, parse_resources, parse_tools};
```
### `types.rs`
All type definitions live here. Key decisions:
- **`JsonRpcRequest`** — `Deserialize + Serialize`. The `id` field is `Option<Value>` because notifications have no id. The `params` field is `Option<Value>` — kept as a raw Value tree and destructured later by the specific handler.
- **`McpResponse`** — The optimized response type returned by `handle()`. Private fields, `pub(crate)` constructors. See [Zero-copy response design](#zero-copy-response-design) below.
- **`JsonRpcResponse`** — Legacy structured response kept for two purposes: (1) deserialization from external JSON-RPC, (2) test inspection via `McpResponse::into_json_rpc()`. Not returned by `handle()`.
- **`Tool`** has `#[serde(skip)]` on `schema_meta` — validation metadata is internal, never serialized to clients. The `#[serde(rename_all = "camelCase")]` on `Tool` means `input_schema` serializes as `inputSchema`, matching the MCP spec.
- **`ToolCallParams`** and **`ResourceReadParams`** are `pub(crate)` — internal deserialize-only structs consumed via `serde_json::from_value()` in the handler methods.
- **`SchemaMeta`** stores parsed validation rules (`required`, `one_of`, `dependencies`) extracted from JSON Schema at load time. This avoids re-parsing the schema on every request.
### `server.rs`
The `Server` struct, builder pattern, handler traits, and all MCP method routing.
**Handler traits:**
```rust
#[async_trait]
pub trait ToolHandler: Send + Sync {
async fn call(&self, args: Value, context: Value) -> Result<ToolResult, McpError>;
}
#[async_trait]
pub trait ResourceHandler: Send + Sync {
async fn call(&self, uri: &str, context: Value) -> Result<ResourceContent, McpError>;
}
```
Both take `&self` — handlers are shared via `Arc<dyn ToolHandler>` in the HashMap. Both receive a `context: Value` that carries request-scoped data from the HTTP layer (decoded JWT claims, tenant info, etc.). See [Request context](#request-context) below.
**`FnToolHandler::new()` returns `Arc<dyn ToolHandler>` directly** — not `Self`. This means closure-based handlers can be registered without the caller wrapping in `Arc`:
```rust
```rust
fn handle_tools_list(&self, id: Option<Value>) -> McpResponse {
McpResponse::cached(id, &self.tools_list_result)
}
```
### Custom `Serialize` for `McpResponse`
`McpResponse` has a hand-written `Serialize` impl using `serialize_map`. For the `Cached` variant, it embeds the `RawValue` verbatim — the JSON bytes are copied directly to the output buffer without parsing or tree-walking:
```rust
ResponseKind::Cached(raw) => map.serialize_entry("result", raw.as_ref())?,
```
This requires `serde_json` with the `raw_value` feature enabled in `Cargo.toml`:
```toml
serde_json = { version = "1", features = ["raw_value"] }
```
### `ResponseKind` enum
```rust
enum ResponseKind {
Cached(Arc<RawValue>), // Pre-serialized, zero-copy
Result(Value), // Dynamic (tools/call, resources/read)
Error(RpcError), // Error response
Notification, // No body (HTTP 202)
}
```
- `Cached` — for endpoints whose response never changes (`initialize`, `tools/list`, `resources/list`)
- `Result` — for dynamic endpoints (`tools/call`, `resources/read`) where the Value is constructed per-request
- `Error` — JSON-RPC error
- `Notification` — sentinel; the HTTP layer returns 202 with empty body
### `into_json_rpc()` for test inspection
Since `McpResponse` fields are private and `Cached` holds raw bytes, tests use `into_json_rpc()` to convert back to `JsonRpcResponse`. This re-parses the `RawValue` into a `Value` tree — acceptable in tests, never in production.
## Build-time serialization order
In `ServerBuilder::build()`, the order of operations matters:
1. **Pre-serialize** `tools_list_result` and `resources_list_result` from `self.tools` / `self.resources` (borrows the Vecs)
2. **Then** consume the Vecs via `into_iter()` to build HashMaps (moves the structs)
This avoids cloning — the Vecs are borrowed for JSON serialization, then moved into maps. Only the `name` String is cloned (for the HashMap key); the `Tool`/`Resource` structs themselves are moved.
```rust
// Step 1: borrow for serialization
let tools_list_result = Arc::from(to_raw(&json!({ "tools": self.tools })));
// Step 2: consume by move
let tool_map: HashMap<String, Tool> = self.tools.into_iter()
.map(|t| { let name = t.name.clone(); (name, t) })
.collect();
```
## Move semantics in `handle()`
`handle()` takes `JsonRpcRequest` and `context` by value and destructures them:
```rust
pub async fn handle(&self, req: JsonRpcRequest, context: Value) -> McpResponse {
match req.method.as_str() {
"tools/call" => self.handle_tools_call(req.id, req.params, context).await,
...
}
}
```
`req.id`, `req.params`, and `context` are moved into the sub-handler that runs. For cached endpoints (`initialize`, `tools/list`, `resources/list`), the context is simply dropped — it was never forwarded.
Inside `handle_tools_call`, the `params: Option<Value>` is consumed by `serde_json::from_value(p)` which takes ownership and destructures the Value tree without cloning. The `context` is moved directly to the handler's `call()` method.
The tool definition and handler are looked up by borrowing from the HashMaps (`self.tools.get()`, `self.tool_handlers.get()`). No struct cloning.
## Request context
The `context: Value` parameter on `handle()` carries request-scoped data from the HTTP layer to tool/resource handlers. The library is completely agnostic about its contents — it simply moves the value through.
**The flow:**
1. HTTP middleware decodes the JWT (or extracts API key metadata, etc.)
2. The Axum handler builds a `Value` with the claims: `json!({"sub": "user-123", "tenant_id": "acme"})`
3. Passes it to `server.handle(req, context)`
4. `handle()` moves it to `handle_tools_call()` or `handle_resources_read()`
5. The handler method moves it to `handler.call(args, context)`
6. The tool/resource handler reads whatever fields it needs
**Zero clones.** The `Value` is constructed once in the HTTP layer and moved at every step. For cached endpoints it's dropped without ever being read.
**No auth opinion.** The library doesn't know about JWTs, Cognito, Auth0, or any specific provider. It just passes a `Value` through. The HTTP layer decides what goes in it.
**Handlers that don't need context** simply ignore it:
```rust
Struct-based handlers must be wrapped in `Arc::new()` by the caller. Closure-based handlers get this for free from `FnToolHandler::new()`.
## Tool and resource definitions
Tools and resources are defined as JSON arrays, not in Rust code. This is intentional — definitions change frequently and JSON is easier to edit than Rust structs. The builder supports three sources:
- `tools_file("path.json")` — load from disk
- `tools_json(bytes)` — parse from raw bytes (useful for embedded JSON or tests)
- `tools(vec)` — pass pre-built `Vec<Tool>` directly
Same for resources.
## Schema validation
Validation happens at the protocol layer, before the handler is called. If arguments fail validation, the handler never executes and a JSON-RPC error is returned.
Three schema features are supported:
- **`required`** — field must exist in the arguments object
- **`oneOf`** — at least one set of required fields must all be present
- **`dependencies`** — if field A is present, fields B, C, ... must also be present
These cover the common patterns needed for MCP tools. Full JSON Schema validation (type checking, patterns, ranges) is intentionally not implemented — it would add complexity without proportional value for typical MCP use cases.
## Error handling
- **Validation errors** → JSON-RPC error with code `-32602` (bad params)
- **Unknown tool** → JSON-RPC error with code `-32601` (method not found)
- **No handler registered** → JSON-RPC error with code `-32603` (internal error)
- **Handler returns `Err(McpError)`** → converted to `error_result()` (tool result with `is_error: true`), not a JSON-RPC error. This matches MCP spec — tool execution errors are content, not protocol errors.
## The HTTP layer (application concern)
The `examples/basic_server.rs` is the reference integration. Key patterns:
- **Session management**: Create UUID on `initialize`, store in `RwLock<HashSet<String>>`, pass via `mcp-session-id` header. Entirely application-level.
- **Notification → 202**: Check `resp.is_notification()`, return `StatusCode::ACCEPTED` with empty body.
- **Normal response → JSON**: `Json(&resp)` — the custom `Serialize` impl handles cached vs dynamic transparently.
- **Multiple endpoints**: Mount the same handler on different routes with different middleware for public/private access.
## Protocol version
The library implements MCP specification **2025-03-26**. The version string is defined as:
```rust
pub const PROTOCOL_VERSION: &str = "2025-03-26";
```
This is returned in the `initialize` response and should be updated when implementing a newer spec version.
## Supported MCP methods
| `initialize` | Cached | Returns capabilities, server info, protocol version |
| `ping` | Dynamic | Returns `{}` |
| `tools/list` | Cached | Returns all registered tool definitions |
| `tools/call` | Dynamic | Validates args, dispatches to handler |
| `resources/list` | Cached | Returns all registered resource definitions |
| `resources/read` | Dynamic | Looks up by name or URI, dispatches to handler |
| `notifications/initialized` | Notification | No response body (HTTP 202) |
| `notifications/cancelled` | Notification | No response body (HTTP 202) |
## Dependencies rationale
| `serde` + `serde_json` | JSON serialization. `raw_value` feature required for `RawValue` / zero-copy. |
| `async-trait` | Handler traits need `async fn` in traits. Can be removed once async trait fns stabilize in Rust. |
| `tracing` | Structured logging in `handle_initialize`. No subscriber — that's the app's job. |
| `thiserror` | Derive `Error` for `McpError` enum. |
Everything else (`axum`, `tokio`, `uuid`, etc.) is in `[dev-dependencies]` for tests and examples only.