You are reading the docs for rws 18.1 The latest release is 18.8. Read this page for the latest release.

MCP Server Overview

Expose tools, resources, and prompts to AI agents via the Model Context Protocol over HTTP.

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 specification up to 2025-11-25 with no external dependencies, and speaks each version from 2024-11-05 on to the clients that ask for it — 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
app.mcp() shorthand

App::new().mcp(name, version) is equivalent to McpServer::new(name, version) with the built-in App automatically wired as the fallback. Both forms pass directly to Server::run.

AppWithState and WithMiddleware have the same .mcp() method, so MCP can sit on top of your own routes — the dispatch chain is McpServer → your routes → the built-in controllers:

use rust_web_server::app::App;
use rust_web_server::mcp::McpContent;
use rust_web_server::request::Request;
use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
use rust_web_server::router::PathParams;
use rust_web_server::routes;
use rust_web_server::server::{ConnectionInfo, Server};

#[derive(Clone)]
struct State { version: &'static str }

fn get_version(_req: &Request, _p: &PathParams, _c: &ConnectionInfo, _s: &State) -> Response {
    let mut r = Response::new();
    r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
    r
}

let http = routes! {
    App::with_state(State { version: "1.0.0" }),
    GET "/api/version" => get_version,
};

let mut mcp = http.mcp("my-app", "1.0.0");
if let Ok(token) = std::env::var("MCP_TOKEN") {
    mcp = mcp.require_bearer(token);
}
let app = mcp.tool(
    "server_version",
    "Return the running server version",
    r#"{"type":"object"}"#,
    |_| Ok(McpContent::json(r#"{"version":"1.0.0"}"#)),
);

let (listener, pool) = Server::setup().unwrap();
Server::run(listener, pool, app);

MCP endpoint

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

JSON-RPC method Purpose
initialize Capability negotiation and server info
ping Liveness check
tools/list List all registered tools
tools/call Invoke a tool by name
resources/list List all registered resources
resources/read Read a resource by URI
prompts/list List all registered prompt templates
prompts/get Retrieve a rendered prompt by name
resources/subscribe / resources/unsubscribe Watch a resource URI — see Resource subscriptions
logging/setLevel Set the minimum pushed log level — see Logging
completion/complete Argument autocompletion — see Argument autocompletion

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. Batching was removed from MCP in 2025-06-18, so batches are accepted only from clients on 2025-03-26 or earlier (by session, or MCP-Protocol-Version; no header and no session counts as 2025-03-26); others get a -32600 error:

// 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 [].

initialize inside a batch

If a batch includes a successful initialize, the first one mints a session and attaches Mcp-Session-Id to the overall response, same as a standalone initialize would. Sending more than one initialize in a single batch is unusual and only the first is honored for session purposes — one HTTP response can only carry one session id.

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.

The cursor is opaque

The cursor is base64 of a decimal offset, but treat it as an opaque token — don't construct or parse it yourself. A malformed or tampered cursor gets a JSON-RPC INVALID_PARAMS (-32602) error rather than silently resetting to the first page. An offset past the end of the list returns an empty page with no nextCursor, not an error.

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"}}
Backpressure and disconnection

.notify() never blocks the calling thread. Each connected client has a bounded 32-frame buffer; a client that isn't reading fast enough (buffer full) is dropped from the broadcast list exactly like a disconnected one — one slow or stuck client can never stall notifications to everyone else. Idle connections receive a : keep-alive SSE comment every 15 seconds, both so intermediate proxies don't time out a silent connection and because it forces a write attempt that reveals a dead peer.

The stream works over HTTP/1.1, HTTP/2 and HTTP/3 alike: each delivers events as they are produced (tested against real HTTP/2 and HTTP/3 clients).

Logging

Deprecated by MCP 2026-07-28

The logging capability (logging/setLevel, notifications/message, .log()) is deprecated in the 2026-07-28 MCP specification (SEP-2577). It keeps working with clients that use it, for at least twelve months; new servers should log through their own logging instead.

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.

.logging_enabled() doesn't gate functionality

Calling .logging_enabled() only changes what's advertised in initialize's capabilities — .log() and logging/setLevel both work whether or not it was called. A spec-honest client simply wouldn't send logging/setLevel in the first place without seeing the capability advertised, so pairing them is expected but not enforced.

.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 is advertised as true too — see Resource subscriptions.

No dynamic equivalent for every builder variant

.register_tool() and .register_prompt() only cover the plain shapes — matching .tool() and .prompt(). There's no dynamic equivalent of .tool_with_context(), .tool_annotated(), or .prompt_with_args(). To change a dynamically-added tool's annotations or a prompt's argument definitions, remove it and register it again under the same name.

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}}
Large result sets

A handler returning more than 100 values gets truncated to the first 100, with hasMore:true and the untruncated total reported — matching the spec's guidance that servers shouldn't return huge completion lists in one response.

initialize advertises this automatically

"completions":{} appears in initialize's capabilities as soon as at least one .completion() has been registered — there's no separate opt-in flag to call, unlike .logging_enabled(). A server with no completions registered simply doesn't advertise the capability, since completion/complete would just return empty results for everything anyway.

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.

A client that disconnects without unsubscribing leaves a stale entry

Nothing proactively notices a GET /mcp connection closing and removes its subscriptions — the same "no eviction without an explicit call" tradeoff already accepted for session storage elsewhere in this server. The stale session id sits harmlessly in the subscriber list for that URI (it just won't have a live SSE connection to actually deliver to) until an explicit resources/unsubscribe removes it.

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

Protocol version negotiation

The server speaks MCP 2024-11-05, 2025-03-26, 2025-06-18 and 2025-11-25. initialize confirms the client's params.protocolVersion when it is one of those, and answers anything else — older, newer, malformed or missing — with the newest, 2025-11-25; the client then decides whether it can continue:

// A client on 2025-06-18 gets 2025-06-18:
{"method": "initialize", "params": {"protocolVersion": "2025-06-18", "clientInfo": {"name": "my-client", "version": "1.0"}}}
{"result": {"protocolVersion": "2025-06-18", "capabilities": {...}, "serverInfo": {...}}}

The session remembers the version. After initialize, clients on 2025-06-18 and later send MCP-Protocol-Version on every request; a version the server doesn't speak gets 400, and a request without the header is taken as 2025-03-26 (as the transport specifies).

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.

Sessions

initialize mints a session and returns its id in Mcp-Session-Id; the client sends it on later requests. Sessions are bounded (since 18.0.0):

  • A session idle for an hour is evicted — .session_ttl(Duration) changes that. Any use keeps it alive.
  • At most 10 000 sessions are kept (.max_sessions(n)); a new one past that evicts the least recently used.
  • DELETE /mcp with the session's Mcp-Session-Id ends it (204).
  • A request carrying a session id the server doesn't know — never issued, ended or evicted — gets 404, the transport's signal to send initialize again. An initialize carrying a stale id is accepted.
  • An ended or evicted session's resource subscriptions and GET /mcp streams go with it.
  • At most 1000 GET /mcp streams are open at once (.max_sse_clients(n)); another gets 503 with Retry-After.
use std::time::Duration;
use rust_web_server::mcp::McpServer;

let mcp = McpServer::new("my-server", "1.0")
    .session_ttl(Duration::from_secs(15 * 60))
    .max_sessions(1000)
    .max_sse_clients(100);

Built-in rws tools

The rws binary serves /mcp with 20 built-in tools when MCP_TOKEN is set (required as a Bearer token; without it there is no /mcp), not in config-driven proxy mode. See Operate rws from an AI client for setup and playbooks:

Tool name Description
server_config Active RWS_CONFIG_* values
feature_flags List compiled feature flags
server_metrics Live request/error/connection counters
list_routes Registered HTTP routes
cache_stats Response cache hit/miss and entry counts
cache_clear Evict all response cache entries
rate_limit_config Current rate limit settings
check_rate_limit Check remaining quota for a client IP
cors_config Active CORS rules
list_static_files Files served from the static root
reload_config Trigger a hot config reload
maintenance_status Whether maintenance mode is on
set_maintenance Turn maintenance mode on/off
block_ip / unblock_ip Add/remove an IP on the runtime blocklist
list_blocked_ips Currently blocked IPs
feature_list Runtime feature flags and their state
feature_set Enable/disable a runtime feature flag
recent_requests Last N requests (default 20, max 100)
recent_errors Last N 4xx/5xx requests (default 20, max 100)

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"
    }
  }
}

Edit this page on GitHub