Skip to content

MCP Server Overview

The Model Context Protocol (MCP) is a JSON-RPC 2.0 standard that lets AI agents (Claude Desktop, Cursor, custom agents) call server-defined tools, read resources, and retrieve prompt templates over plain HTTP. rust-web-server ships a first-class McpServer that implements the MCP 2024-11-05 specification with no external dependencies, and negotiates that version down for clients that ask for something different — see Protocol version negotiation below.

Creating an MCP server

use rust_web_server::server::Server;
use rust_web_server::mcp::{McpServer, McpContent, PromptMessage};
let mcp = McpServer::new("my-server", "1.0")
.tool(
"echo",
"Echo text back to the caller",
r#"{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}"#,
|args| {
let text = rust_web_server::mcp::extract_arg(args, "text")
.unwrap_or_else(|| "(nothing)".to_string());
Ok(McpContent::text(text))
},
)
.resource(
"docs://{topic}",
"Documentation",
"Return documentation for a topic",
|uri| Ok(McpContent::text(format!("Docs for: {uri}"))),
)
.prompt(
"summarize",
"Summarize the given text",
|args| {
let text = rust_web_server::mcp::extract_arg(args, "text")
.unwrap_or_else(|| "some text".to_string());
Ok(vec![PromptMessage::user(format!("Please summarize: {text}"))])
},
);
// Pass directly to the server — McpServer implements Application.
// let (listener, pool) = Server::setup().unwrap();
// Server::run(listener, pool, mcp);

Attaching MCP to an existing app

If you already have routes, state, or middleware, use .wrap() so that non-MCP requests fall through to your existing Application:

use rust_web_server::app::App;
use rust_web_server::core::New;
use rust_web_server::mcp::{McpContent};
let server = App::new()
.mcp("my-server", "1.0")
.tool("ping", "Ping the server", "{}", |_| Ok(McpContent::text("pong")))
.wrap(App::new()); // non-MCP requests handled by the built-in App

MCP endpoint

All JSON-RPC 2.0 messages travel over POST /mcp. The endpoint handles the full MCP lifecycle:

JSON-RPC methodPurpose
initializeCapability negotiation and server info
pingLiveness check
tools/listList all registered tools
tools/callInvoke a tool by name
resources/listList all registered resources
resources/readRead a resource by URI
prompts/listList all registered prompt templates
prompts/getRetrieve a rendered prompt by name

OPTIONS /mcp is handled for CORS preflight, and GET /mcp opens an SSE stream for server → client push — see SSE streaming transport below. All other HTTP methods return 405 Method Not Allowed.

Override the default path with .at("/custom-path") if needed.

Batch requests

POST /mcp also accepts a top-level JSON array instead of a single object — a JSON-RPC 2.0 batch request, letting a client send several calls in one HTTP round trip:

// Request:
[{"jsonrpc":"2.0","method":"tools/list","id":1},
{"jsonrpc":"2.0","method":"ping","id":2}]
// Response — one entry per element, in order:
[{"jsonrpc":"2.0","result":{"tools":[...]},"id":1},
{"jsonrpc":"2.0","result":{},"id":2}]

Each element is dispatched through the same method table as a standalone request, and each one’s success or error is independent — one element failing (e.g. an unknown method) doesn’t affect the others or fail the batch as a whole.

Elements with no id (notifications) contribute no entry to the response array, exactly like a standalone notification produces no response body. A batch made up entirely of notifications returns 202 Accepted with an empty body. An empty array ([]) is itself invalid per the JSON-RPC spec — it gets back a single Invalid Request error object rather than an empty [].

Pagination

tools/list, resources/list, and prompts/list return every registered item in one response by default. For a server with a lot of tools or resources, call .page_size(n) when building the server to cap each response to n items and enable cursor-based pagination:

use rust_web_server::mcp::McpServer;
let server = McpServer::new("my-server", "1.0").page_size(50);

A response with more items remaining includes "nextCursor" — an opaque string the client echoes back as params.cursor on its next call to get the next page:

// First call — no cursor:
{"method":"tools/list","params":{}}
// → {"result":{"tools":[...50 items...],"nextCursor":"NTA="}}
// Next call — cursor from the previous response:
{"method":"tools/list","params":{"cursor":"NTA="}}
// → {"result":{"tools":[...remaining items...]}} — no nextCursor once exhausted

Claude Desktop and other MCP clients already send cursor back automatically once a nextCursor appears in a response — no extra client-side wiring is needed.

SSE streaming transport

The MCP Streamable HTTP spec defines a second transport alongside POST /mcp: a client that sends GET /mcp instead gets back a text/event-stream response that stays open indefinitely, for server → client push (log messages, progress updates, list-changed notifications, and anything else you want to push proactively).

Call .notify(method, params_json) from anywhere in your code — a background thread, a webhook handler, another tool’s own handler — to push a JSON-RPC notification to every client currently connected to the SSE stream:

use rust_web_server::mcp::McpServer;
let server = McpServer::new("my-server", "1.0");
// Elsewhere, e.g. after a background job finishes:
server.notify("notifications/message", Some(r#"{"level":"info","data":"job finished"}"#));

params_json, if given, must already be valid JSON (usually an object) — it’s spliced into the notification verbatim, not escaped or re-serialized. method alone (no id) matches how the JSON-RPC spec defines a notification: fire-and-forget, no response expected.

// What a connected client sees on the SSE stream after the call above:
data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"job finished"}}

Logging

The spec lets a client request a minimum log level and receive server log messages pushed as notifications/message events over the SSE stream — useful during development, since an MCP client like Claude Desktop can show server diagnostics inline instead of you tailing a separate log file.

Call .logging_enabled() when building the server to advertise the logging capability in initialize, then call .log(level, logger, data_json) from anywhere in your code to push an entry:

use rust_web_server::mcp::{LogLevel, McpServer};
let server = McpServer::new("my-server", "1.0").logging_enabled();
// Elsewhere in your code, e.g. inside a tool handler or a background thread:
server.log(LogLevel::Warning, Some("database"), r#""connection pool exhausted""#);

LogLevel is the MCP spec’s eight RFC 5424 syslog severities, from most to least severe: Debug, Info, Notice, Warning, Error, Critical, Alert, Emergency. A connected client can narrow which levels it wants delivered by calling logging/setLevel:

{"method":"logging/setLevel","params":{"level":"warning"}}

After that call, only .log() calls at Warning or more severe are pushed to that client — Debug, Info, and Notice calls are silently filtered. Before any client calls logging/setLevel, the default minimum is Debug — the least restrictive level, so nothing is filtered until a client asks for less noise.

logger (optional) identifies the log’s source — a module name, a subsystem, whatever’s meaningful in your app — and appears as the logger field on the pushed notification. data_json must already be valid JSON (an object, a string, a number — the spec allows any type) and is spliced in verbatim.

.log() is built directly on .notify() and inherits its behavior: it never blocks the calling thread, and a client whose event buffer fills up is dropped from the broadcast list exactly like a disconnected one.

Dynamic registration

.tool(), .resource(), and .prompt() are consuming builders — call them once while constructing the server, before it starts serving requests. For tools/resources/prompts that only become known later (a plugin discovered at startup, a database connection, a hot-reloaded config file), use the &self equivalents instead, callable at any time from any thread holding a clone of the server:

use rust_web_server::mcp::{McpContent, McpServer};
let server = McpServer::new("my-server", "1.0");
// Later, from any thread holding a clone of `server`:
server.register_tool("refresh_cache", "Reload the in-memory cache", "{}", |_args| {
Ok(McpContent::text("cache refreshed"))
});
let existed = server.remove_tool("refresh_cache"); // -> true

The matching pairs are .register_tool(...)/.remove_tool(name), .register_resource(...)/.remove_resource(uri_template), and .register_prompt(...)/.remove_prompt(name) — each remove_* returns bool (whether something was actually found and removed).

This works because tools/resources/prompts are stored behind Arc<RwLock<Vec<_>>> rather than a plain list, so every clone of McpServer — each connection gets one — shares the same live data. A tool handler is looked up and its Arc cloned out from under a brief read-lock before being called, so a slow-running tool never blocks a concurrent registration on another thread.

Every registration or removal that actually changes something pushes the corresponding notification to every GET /mcp SSE client:

{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}

(Similarly notifications/resources/list_changed and notifications/prompts/list_changed — none of these carry a params field, per spec.) A removal that finds nothing (an unknown name) pushes no notification.

initialize now advertises "listChanged":true for tools, resources, and prompts unconditionally — dynamic registration is always available, unlike logging which needs .logging_enabled(). resources.subscribe stays false: resources/subscribe/resources/unsubscribe aren’t implemented yet.

Argument autocompletion

Clients like Cursor and VS Code call completion/complete to offer autocomplete suggestions while the user fills in a tool or prompt argument, instead of leaving it a plain text box. Register a provider with .completion(ref_type, ref_name, handler):

use rust_web_server::mcp::McpServer;
let server = McpServer::new("my-server", "1.0")
.completion("tool", "deploy", |arg_name, partial| {
match arg_name {
"region" => Ok(vec!["us-east-1", "eu-west-1", "ap-southeast-1"]
.into_iter()
.filter(|r| r.starts_with(partial))
.map(String::from)
.collect()),
_ => Ok(vec![]),
}
});

ref_type is "tool" or "prompt" — matched against the request’s ref.type ("ref/tool"/"ref/prompt" on the wire) with the "ref/" prefix stripped. ref_name is the tool or prompt name this applies to. The handler receives the argument’s name and whatever partial value the user has typed so far, and returns candidate completion strings.

A completion/complete request with no matching registration — an unrecognized ref/name, or an argument name the handler doesn’t branch on — gets back an empty values array rather than an error; completion is a best-effort hint, not something every tool or prompt is required to support.

{"completion":{"values":["us-east-1"],"hasMore":false,"total":1}}

There’s no dynamic (&self) equivalent of .completion() — unlike tools/resources/prompts, completion providers are registered only via the consuming builder, before the server starts serving requests.

Resource subscriptions

A client can subscribe to a specific resource URI and receive notifications/resources/updated whenever it changes — the mechanism behind live-updating resource panels in clients like Claude Desktop (watching a config file, a dashboard resource, or anything else that changes outside of a direct tools/call).

Call .notify_resource_updated(uri) from wherever your application actually changes the underlying data — a file watcher, a webhook handler, a poll loop:

use rust_web_server::mcp::McpServer;
let server = McpServer::new("my-server", "1.0");
// Elsewhere, e.g. after reloading a watched config file:
server.notify_resource_updated("config://main");

Unlike every other notification this server pushes (.notify(), .log(), list_changed), which broadcast to every connected GET /mcp SSE client, notify_resource_updated is targeted: only sessions that called resources/subscribe for that exact URI receive it. This is why resources/subscribe and resources/unsubscribe both require an Mcp-Session-Id header (from a prior initialize call) — without one there’s no way to later match a subscription back to a specific GET /mcp SSE connection.

// A client subscribes:
{"method":"resources/subscribe","params":{"uri":"config://main"}}
// Later, on that same session's GET /mcp SSE stream:
data: {"jsonrpc":"2.0","method":"notifications/resources/updated","params":{"uri":"config://main"}}

resources/unsubscribe removes the subscription; once a URI has zero subscribers, its bookkeeping entry is dropped entirely rather than left as an empty list.

initialize’s resources capability now advertises "subscribe":true unconditionally, alongside the already-unconditional listChanged:true.

Protocol version negotiation

initialize inspects the client’s requested params.protocolVersion and responds with the lower of that and the server’s own version, rather than always claiming its own regardless of what the client asked for:

// Client requests a newer version than this server implements:
{"method": "initialize", "params": {"protocolVersion": "2025-06-18", "clientInfo": {"name": "my-client", "version": "1.0"}}}
// Server responds with the version it actually speaks — not "2025-06-18":
{"result": {"protocolVersion": "2024-11-05", "capabilities": {...}, "serverInfo": {...}}}

Version strings are YYYY-MM-DD dates, so a plain string comparison already orders them correctly — no date parsing needed. A client requesting an older version than the server’s is honored as sent (the server confirms it’ll speak that version) rather than being overridden. If protocolVersion or params is missing entirely, initialize doesn’t error — it falls back to the server’s own version, same as before this negotiation existed.

params.clientInfo (if the client sends it) is logged to stderr at initialize time and recorded under a freshly minted session id, returned to the client via an Mcp-Session-Id response header — see Per-request context for how a .tool_with_context() handler gets it back on later requests in the same session.

Built-in rws tools

The binary ships 8 built-in tools when run in MCP mode via app.mcp(...):

Tool nameDescription
server_configReturn current server configuration
feature_flagsList compiled feature flags
server_metricsPrometheus-format metrics snapshot
rate_limit_configCurrent rate limit settings
check_rate_limitCheck remaining quota for a client IP
cors_configActive CORS rules
list_static_filesFiles served from the static root
reload_configTrigger a hot config reload

Connecting Claude Desktop

Add the server to ~/Library/Application Support/Claude/claude_desktop_config.json:

{
"mcpServers": {
"my-server": {
"url": "http://localhost:7878/mcp"
}
}
}

With bearer token authentication:

{
"mcpServers": {
"my-server": {
"url": "http://localhost:7878/mcp",
"headers": {
"Authorization": "Bearer your-token-here"
}
}
}
}

Connecting Cursor

In Cursor settings under MCP Servers, add:

{
"my-server": {
"url": "http://localhost:7878/mcp"
}
}

Or for HTTPS deployments:

{
"my-server": {
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer your-token-here"
}
}
}