Skip to main content

rust_web_server/mcp/
mod.rs

1//! Model Context Protocol (MCP) server — HTTP Streamable HTTP transport.
2//!
3//! [`McpServer`] implements [`Application`] so it can be passed directly to
4//! [`Server::run`]. Unmatched requests fall through to the built-in [`App`]
5//! controller chain (static files, health probes, etc.).
6//!
7//! # Quick start
8//!
9//! ```rust,no_run
10//! use rust_web_server::server::Server;
11//! use rust_web_server::mcp::{McpServer, McpContent, PromptMessage};
12//! # fn main() {
13//! let mcp = McpServer::new("my-server", "1.0")
14//!     // A tool: callable by the AI, like a function
15//!     .tool(
16//!         "echo",
17//!         "Echo text back",
18//!         r#"{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}"#,
19//!         |args| {
20//!             let text = rust_web_server::mcp::extract_arg(args, "text")
21//!                 .unwrap_or_else(|| "(nothing)".to_string());
22//!             Ok(McpContent::text(text))
23//!         },
24//!     )
25//!     // A resource: data the AI can read by URI
26//!     .resource(
27//!         "docs://{topic}",
28//!         "Documentation",
29//!         "Return documentation for a topic",
30//!         |uri| Ok(McpContent::text(format!("Documentation for: {uri}"))),
31//!     )
32//!     // A prompt template: reusable message structures
33//!     .prompt(
34//!         "summarize",
35//!         "Summarize the given text",
36//!         |args| {
37//!             let text = rust_web_server::mcp::extract_arg(args, "text")
38//!                 .unwrap_or_else(|| "some text".to_string());
39//!             Ok(vec![PromptMessage::user(format!("Please summarize: {text}"))])
40//!         },
41//!     );
42//!
43//! // let (listener, pool) = Server::setup().unwrap();
44//! // Server::run(listener, pool, mcp);
45//! # }
46//! ```
47//!
48//! # MCP endpoint
49//!
50//! All JSON-RPC messages are sent as `POST /mcp` (override with [`.at()`](McpServer::at)).
51//! The server implements the [MCP 2024-11-05 specification](https://spec.modelcontextprotocol.io).
52//!
53//! `GET /mcp` opens a Server-Sent Events stream for server → client push —
54//! see [`McpServer::notify`] and the module docs' SSE section below.
55//!
56//! # SSE streaming transport
57//!
58//! A client that sends `GET /mcp` (instead of `POST`) gets back a
59//! `text/event-stream` response that stays open indefinitely. Call
60//! [`McpServer::notify`] from anywhere (a background thread, another request's
61//! handler, ...) to push a JSON-RPC notification to every currently-connected
62//! SSE client:
63//!
64//! ```rust,no_run
65//! use rust_web_server::mcp::McpServer;
66//!
67//! let server = McpServer::new("my-server", "1.0");
68//! // Elsewhere, e.g. after some background job finishes:
69//! server.notify("notifications/message", Some(r#"{"level":"info","data":"job done"}"#));
70//! ```
71//!
72//! Idle connections receive a `: keep-alive` SSE comment every 15 seconds so
73//! intermediate proxies don't time them out; this doubles as the mechanism
74//! that detects a client has disconnected (the next write attempt fails and
75//! the connection is dropped). A client whose event buffer fills up (32
76//! pending frames, unconsumed) is treated the same as a disconnected one and
77//! dropped from the broadcast list — [`McpServer::notify`] never blocks the
78//! calling thread waiting on a slow reader.
79//!
80//! This transport is only wired up for the plain HTTP/1.1 path
81//! (`Server::run`/`Server::process`) — same scope as `Response::stream_pipe`
82//! generally, which the HTTP/2 (`h2_handler`) and HTTP/3 (`h3_handler`)
83//! handlers don't yet support for *any* response, not just this one.
84//!
85//! # Environment variables
86//!
87//! None — configure the server programmatically via the builder.
88
89mod json_rpc;
90
91#[cfg(test)]
92mod tests;
93
94use std::collections::HashMap;
95use std::sync::mpsc::{self, SyncSender};
96use std::sync::{Arc, Mutex, RwLock};
97use std::time::Duration;
98
99use crate::app::App;
100use crate::application::Application;
101use crate::core::New;
102use crate::header::Header;
103use crate::mime_type::MimeType;
104use crate::range::Range;
105use crate::request::Request;
106use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
107use crate::server::ConnectionInfo;
108
109const PROTOCOL_VERSION: &str = "2024-11-05";
110
111// ── public content types ──────────────────────────────────────────────────────
112
113/// Content returned by tool and resource handlers.
114///
115/// Create with [`McpContent::text`] (plain text or JSON strings),
116/// [`McpContent::json`] (marks MIME type as `application/json`),
117/// [`McpContent::image`] (base64-encoded binary image data), or
118/// [`McpContent::embedded`] (a resource embedded inline in a tool response).
119#[derive(Clone, Debug)]
120pub struct McpContent {
121    /// `"text"`, `"image"`, or `"resource"`.
122    pub kind: &'static str,
123    /// The content string — text for `"text"`, base64 data for `"image"`,
124    /// or the embedded resource's text for `"resource"`.
125    pub text: String,
126    /// Optional MIME type override (default `"text/plain"` for `"text"`;
127    /// required in practice for `"image"`/`"resource"`, set by their
128    /// constructors).
129    pub mime_type: Option<String>,
130    /// The resource URI — only set (and only serialized) for `"resource"`.
131    pub uri: Option<String>,
132}
133
134impl McpContent {
135    /// Plain-text content.
136    pub fn text(s: impl Into<String>) -> Self {
137        McpContent { kind: "text", text: s.into(), mime_type: None, uri: None }
138    }
139
140    /// JSON content — sets `mimeType` to `application/json`.
141    pub fn json(s: impl Into<String>) -> Self {
142        McpContent { kind: "text", text: s.into(), mime_type: Some("application/json".to_string()), uri: None }
143    }
144
145    /// Image content (screenshot, chart, generated art) — `data` is base64-encoded
146    /// binary and `mime_type` is e.g. `"image/png"`.
147    pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
148        McpContent { kind: "image", text: data.into(), mime_type: Some(mime_type.into()), uri: None }
149    }
150
151    /// A resource embedded inline in a tool response, as opposed to one a
152    /// client fetches separately via `resources/read`.
153    pub fn embedded(uri: impl Into<String>, text: impl Into<String>, mime_type: impl Into<String>) -> Self {
154        McpContent { kind: "resource", text: text.into(), mime_type: Some(mime_type.into()), uri: Some(uri.into()) }
155    }
156
157    fn to_content_json(&self) -> String {
158        match self.kind {
159            "image" => format!(
160                r#"{{"type":"image","data":"{}","mimeType":"{}"}}"#,
161                json_escape(&self.text),
162                json_escape(self.mime_type.as_deref().unwrap_or("application/octet-stream")),
163            ),
164            "resource" => format!(
165                r#"{{"type":"resource","resource":{{"uri":"{}","mimeType":"{}","text":"{}"}}}}"#,
166                json_escape(self.uri.as_deref().unwrap_or("")),
167                json_escape(self.mime_type.as_deref().unwrap_or("text/plain")),
168                json_escape(&self.text),
169            ),
170            _ => format!(r#"{{"type":"text","text":"{}"}}"#, json_escape(&self.text)),
171        }
172    }
173
174    fn mime(&self) -> &str {
175        self.mime_type.as_deref().unwrap_or("text/plain")
176    }
177}
178
179/// A single message in a prompt response.
180#[derive(Clone, Debug)]
181pub struct PromptMessage {
182    /// `"user"` or `"assistant"`.
183    pub role: &'static str,
184    /// The message content.
185    pub content: McpContent,
186}
187
188impl PromptMessage {
189    /// Build a user-role message.
190    pub fn user(text: impl Into<String>) -> Self {
191        PromptMessage { role: "user", content: McpContent::text(text) }
192    }
193
194    /// Build an assistant-role message.
195    pub fn assistant(text: impl Into<String>) -> Self {
196        PromptMessage { role: "assistant", content: McpContent::text(text) }
197    }
198
199    fn to_json(&self) -> String {
200        format!(
201            r#"{{"role":"{}","content":{}}}"#,
202            self.role,
203            self.content.to_content_json(),
204        )
205    }
206}
207
208/// Argument definition for a prompt template.
209#[derive(Clone)]
210pub struct PromptArgDef {
211    pub name: String,
212    pub description: String,
213    pub required: bool,
214}
215
216impl PromptArgDef {
217    pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
218        PromptArgDef { name: name.into(), description: description.into(), required: true }
219    }
220
221    pub fn optional(name: impl Into<String>, description: impl Into<String>) -> Self {
222        PromptArgDef { name: name.into(), description: description.into(), required: false }
223    }
224}
225
226// ── McpContext ────────────────────────────────────────────────────────────────
227
228/// Per-request context passed to tool handlers registered via
229/// [`McpServer::tool_with_context`] — caller identity and session info that a
230/// plain `Fn(&str) -> ...` tool handler has no way to see.
231///
232/// Constructed in [`McpServer::execute`] from the current request's headers
233/// plus whatever `clientInfo` was recorded for this session at `initialize`
234/// time (see [`McpServer::handle_request_with_context`]).
235#[derive(Debug, Clone, Default)]
236pub struct McpContext {
237    /// `clientInfo.name` sent in this session's `initialize` call, if the
238    /// client sent one and this request carries a recognized `Mcp-Session-Id`.
239    pub client_name: Option<String>,
240    /// `clientInfo.version` sent in this session's `initialize` call, under
241    /// the same conditions as `client_name`.
242    pub client_version: Option<String>,
243    /// The `Mcp-Session-Id` header on this request, if present — the value
244    /// the server minted and returned in the `initialize` response header
245    /// for this session (see the module docs' Sessions section).
246    pub session_id: Option<String>,
247    /// Verified JWT claims as a JSON string. Not populated by anything in
248    /// this crate yet — reserved for a future JWT-auth integration
249    /// (MCP_TODO.md TODO-11/TODO-13); always `None` today.
250    pub auth_claims: Option<String>,
251    /// The raw JSON value of `params._meta.progressToken` from the
252    /// triggering `tools/call` request, if the client sent one — a spec
253    /// `string | number`, so this is stored pre-rendered (already correctly
254    /// quoted if it's a string) rather than decoded, and spliced back
255    /// verbatim by [`Self::report_progress`]. `None` for anything other than
256    /// a `tools/call` whose caller asked for progress updates.
257    pub progress_token: Option<String>,
258    /// Shared handle back to the owning [`McpServer`]'s SSE broadcast list,
259    /// used by [`Self::report_progress`]. Not `pub` — this is plumbing, not
260    /// part of the context data a handler reads. `None` for a context built
261    /// by hand (e.g. via [`McpServer::handle_request_with_context`] in a
262    /// test) rather than through [`McpServer::execute`], in which case
263    /// `report_progress` silently no-ops — there's no live server to
264    /// broadcast through.
265    sse_clients: Option<Arc<Mutex<Vec<SseSender>>>>,
266}
267
268impl McpContext {
269    /// Push a `notifications/progress` event over the SSE channel for this
270    /// request's `progressToken`, if the client asked for progress updates
271    /// (`params._meta.progressToken` on the triggering `tools/call`) and
272    /// this context was built through a live [`McpServer`] (via `execute()`,
273    /// not a bare `McpContext { .. }` — see the `sse_clients` field doc).
274    ///
275    /// Silently does nothing in either case — a handler doesn't need to
276    /// branch on whether progress reporting is actually wired up before
277    /// calling this; it's always safe to call.
278    ///
279    /// `total` and `message` are both optional, matching the spec's
280    /// `notifications/progress` shape: `{"progressToken":...,"progress":...,
281    /// "total":...,"message":"..."}` (with `total`/`message` omitted when not
282    /// given here).
283    ///
284    /// ```rust,no_run
285    /// use rust_web_server::mcp::{McpContent, McpServer};
286    ///
287    /// let server = McpServer::new("my-server", "1.0")
288    ///     .tool_with_context("long_job", "Do something slow", "{}", |ctx, _args| {
289    ///         ctx.report_progress(0.0, Some(100.0), Some("starting"));
290    ///         // ... do work ...
291    ///         ctx.report_progress(100.0, Some(100.0), Some("done"));
292    ///         Ok(McpContent::text("done"))
293    ///     });
294    /// ```
295    pub fn report_progress(&self, progress: f64, total: Option<f64>, message: Option<&str>) {
296        let (Some(token), Some(sse_clients)) = (&self.progress_token, &self.sse_clients) else {
297            return;
298        };
299
300        let total_field = match total {
301            Some(t) => format!(r#","total":{t}"#),
302            None => String::new(),
303        };
304        let message_field = match message {
305            Some(m) => format!(r#","message":"{}""#, json_escape(m)),
306            None => String::new(),
307        };
308        let params = format!(
309            r#"{{"progressToken":{token},"progress":{progress}{total_field}{message_field}}}"#
310        );
311        let json = render_notification("notifications/progress", Some(&params));
312        broadcast_sse_to(sse_clients, &json);
313    }
314}
315
316/// `clientInfo` recorded for one session at `initialize` time, looked up by
317/// `Mcp-Session-Id` for later requests in the same session. See
318/// `McpServer`'s `sessions` field doc comment for the unbounded-growth caveat.
319#[derive(Clone, Default)]
320struct StoredClientInfo {
321    name: Option<String>,
322    version: Option<String>,
323}
324
325// ── ToolAnnotations ───────────────────────────────────────────────────────────
326
327/// Behavioral hints for a tool, per the MCP 2025-03-26 spec's tool
328/// annotations. Clients (Claude Desktop and others) use these to decide
329/// whether to warn or ask for confirmation before calling a tool — e.g. skip
330/// confirmation for a read-only tool, or warn before a destructive one.
331///
332/// Every field is a *hint*, not something this server enforces or verifies —
333/// nothing stops a handler registered with `read_only_hint: Some(true)` from
334/// actually writing to disk. A well-behaved server sets these accurately;
335/// a client is free to ignore them or ask for confirmation anyway.
336///
337/// Register with [`McpServer::tool_annotated`]. Build one with plain struct
338/// syntax — every field defaults to `None` (no hint given, the client's own
339/// default applies):
340///
341/// ```rust
342/// use rust_web_server::mcp::ToolAnnotations;
343///
344/// let destructive = ToolAnnotations {
345///     destructive_hint: Some(true),
346///     read_only_hint: Some(false),
347///     ..Default::default()
348/// };
349/// ```
350#[derive(Debug, Clone, Copy, Default)]
351pub struct ToolAnnotations {
352    /// The tool does not modify its environment.
353    pub read_only_hint: Option<bool>,
354    /// The tool may perform destructive updates (only meaningful when
355    /// `read_only_hint` is not `Some(true)`).
356    pub destructive_hint: Option<bool>,
357    /// Calling the tool repeatedly with the same arguments has no additional
358    /// effect beyond the first call.
359    pub idempotent_hint: Option<bool>,
360    /// The tool may interact with an open-ended set of external entities
361    /// (e.g. web search), as opposed to a fixed, closed set.
362    pub open_world_hint: Option<bool>,
363}
364
365impl ToolAnnotations {
366    /// Render as a JSON object containing only the hints that are `Some`,
367    /// using the spec's camelCase key names. Returns `"{}"` if every field
368    /// is `None`.
369    fn to_json(self) -> String {
370        let mut fields = Vec::with_capacity(4);
371        if let Some(v) = self.read_only_hint {
372            fields.push(format!(r#""readOnlyHint":{v}"#));
373        }
374        if let Some(v) = self.destructive_hint {
375            fields.push(format!(r#""destructiveHint":{v}"#));
376        }
377        if let Some(v) = self.idempotent_hint {
378            fields.push(format!(r#""idempotentHint":{v}"#));
379        }
380        if let Some(v) = self.open_world_hint {
381            fields.push(format!(r#""openWorldHint":{v}"#));
382        }
383        format!("{{{}}}", fields.join(","))
384    }
385}
386
387// ── LogLevel ──────────────────────────────────────────────────────────────────
388
389/// RFC 5424 syslog severity levels, as used by the MCP `logging/setLevel`
390/// request and `notifications/message` log entries — ordered from most to
391/// least verbose so `level < min_level` comparisons work directly (this
392/// relies on declaration order matching severity order; don't reorder the
393/// variants).
394///
395/// ```rust
396/// use rust_web_server::mcp::LogLevel;
397///
398/// assert!(LogLevel::Debug < LogLevel::Warning);
399/// assert!(LogLevel::Emergency > LogLevel::Error);
400/// assert_eq!(LogLevel::parse("warning"), Some(LogLevel::Warning));
401/// assert_eq!(LogLevel::Warning.as_str(), "warning");
402/// ```
403#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
404pub enum LogLevel {
405    Debug,
406    Info,
407    Notice,
408    Warning,
409    Error,
410    Critical,
411    Alert,
412    Emergency,
413}
414
415impl LogLevel {
416    /// Parse the MCP spec's lowercase level name. Returns `None` for
417    /// anything that isn't one of the eight recognized levels.
418    pub fn parse(s: &str) -> Option<Self> {
419        match s {
420            "debug"     => Some(LogLevel::Debug),
421            "info"      => Some(LogLevel::Info),
422            "notice"    => Some(LogLevel::Notice),
423            "warning"   => Some(LogLevel::Warning),
424            "error"     => Some(LogLevel::Error),
425            "critical"  => Some(LogLevel::Critical),
426            "alert"     => Some(LogLevel::Alert),
427            "emergency" => Some(LogLevel::Emergency),
428            _           => None,
429        }
430    }
431
432    /// The MCP spec's lowercase level name, e.g. `"warning"`.
433    pub fn as_str(self) -> &'static str {
434        match self {
435            LogLevel::Debug     => "debug",
436            LogLevel::Info      => "info",
437            LogLevel::Notice    => "notice",
438            LogLevel::Warning   => "warning",
439            LogLevel::Error     => "error",
440            LogLevel::Critical  => "critical",
441            LogLevel::Alert     => "alert",
442            LogLevel::Emergency => "emergency",
443        }
444    }
445}
446
447// ── internal handler registrations ───────────────────────────────────────────
448
449type ToolFn     = Arc<dyn Fn(McpContext, &str) -> Result<McpContent, String>    + Send + Sync>;
450type ResourceFn = Arc<dyn Fn(&str) -> Result<McpContent, String>    + Send + Sync>;
451type PromptFn   = Arc<dyn Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync>;
452
453#[derive(Clone)]
454struct ToolDef {
455    name: String,
456    description: String,
457    input_schema: String,
458    annotations: Option<ToolAnnotations>,
459    handler: ToolFn,
460}
461
462#[derive(Clone)]
463struct ResourceDef {
464    uri_template: String,
465    name: String,
466    description: String,
467    handler: ResourceFn,
468}
469
470#[derive(Clone)]
471struct PromptDef {
472    name: String,
473    description: String,
474    arguments: Vec<PromptArgDef>,
475    handler: PromptFn,
476}
477
478// ── McpServer ─────────────────────────────────────────────────────────────────
479
480/// An HTTP server that implements the MCP 2024-11-05 protocol.
481///
482/// Register tools, resources, and prompts with the builder methods, then pass
483/// the server to [`Server::run`] (or [`Server::run_tls`]) as an [`Application`].
484/// Requests that do not match the MCP endpoint fall through to the built-in
485/// [`App`] controller chain.
486#[derive(Clone)]
487pub struct McpServer {
488    server_name: String,
489    server_version: String,
490    path: String,
491    /// `Arc<RwLock<_>>` (not a plain `Vec`) so a running server's tool list
492    /// can be mutated at runtime — see [`Self::register_tool`]/[`Self::remove_tool`]
493    /// — and every clone of this `McpServer` (each connection thread gets
494    /// one) sees the same live list.
495    tools: Arc<RwLock<Vec<ToolDef>>>,
496    resources: Arc<RwLock<Vec<ResourceDef>>>,
497    prompts: Arc<RwLock<Vec<PromptDef>>>,
498    fallback: Option<Arc<dyn Application + Send + Sync>>,
499    auth_token: Option<String>,
500    /// Max items per page for `tools/list`/`resources/list`/`prompts/list`,
501    /// set via [`Self::page_size`]. `None` (the default) means no pagination
502    /// — every item comes back in one response, same as before pagination
503    /// existed.
504    page_size: Option<usize>,
505    /// `clientInfo` recorded per `Mcp-Session-Id`, minted at `initialize` time.
506    /// `Arc<Mutex<_>>` so every clone of this `McpServer` shares one map.
507    ///
508    /// This map only grows — nothing ever removes an entry, since there's no
509    /// session-termination signal in the MCP Streamable HTTP transport to key
510    /// eviction off of. Fine for the expected usage (a modest, roughly-stable
511    /// set of long-lived AI-agent clients); a public-internet-facing server
512    /// churning through unbounded distinct clients would leak memory here
513    /// with no built-in reaping mechanism.
514    sessions: Arc<Mutex<HashMap<String, StoredClientInfo>>>,
515    /// Senders for every currently-connected `GET /mcp` SSE client, pushed to
516    /// by [`Self::notify`]. `Arc<Mutex<_>>` so every clone of this `McpServer`
517    /// broadcasts to the same set of listeners.
518    ///
519    /// Entries for clients that disconnected (or whose buffer filled up) are
520    /// only pruned lazily, the next time [`Self::notify`] is called and its
521    /// `try_send` fails — not proactively, since nothing else observes the
522    /// underlying `Receiver` closing. A server that never calls `notify`
523    /// after clients disconnect will accumulate dead entries here.
524    sse_clients: Arc<Mutex<Vec<SseSender>>>,
525    /// Whether `initialize`'s advertised `capabilities` includes `"logging":{}`.
526    /// Set via [`Self::logging_enabled`]. This only controls what's
527    /// advertised — [`Self::log`] works regardless, same as [`Self::notify`]
528    /// does; a spec-honest client just wouldn't call `logging/setLevel` in
529    /// the first place if the capability was never advertised.
530    logging_enabled: bool,
531    /// The minimum [`LogLevel`] that [`Self::log`] will actually push,
532    /// settable at runtime by a client's `logging/setLevel` request. Starts
533    /// at [`LogLevel::Debug`] (the least restrictive level, i.e. nothing is
534    /// filtered) until a client requests otherwise.
535    min_log_level: Arc<Mutex<LogLevel>>,
536}
537
538/// One `GET /mcp` SSE client's outbound channel. Bounded so a slow or stuck
539/// client can't grow memory without limit; [`McpServer::notify`] uses
540/// `try_send` (never blocks) and drops any client whose buffer is full.
541type SseSender = SyncSender<Vec<u8>>;
542
543/// Max buffered-but-unread SSE frames per client before it's treated as dead.
544const SSE_CHANNEL_CAPACITY: usize = 32;
545
546/// How often an idle SSE connection gets a `: keep-alive` comment.
547const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
548
549impl McpServer {
550    /// Create a new `McpServer`.  The default MCP endpoint is `POST /mcp`.
551    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
552        McpServer {
553            server_name: name.into(),
554            server_version: version.into(),
555            path: "/mcp".to_string(),
556            tools: Arc::new(RwLock::new(Vec::new())),
557            resources: Arc::new(RwLock::new(Vec::new())),
558            prompts: Arc::new(RwLock::new(Vec::new())),
559            fallback: None,
560            auth_token: None,
561            page_size: None,
562            sessions: Arc::new(Mutex::new(HashMap::new())),
563            sse_clients: Arc::new(Mutex::new(Vec::new())),
564            logging_enabled: false,
565            min_log_level: Arc::new(Mutex::new(LogLevel::Debug)),
566        }
567    }
568
569    /// Cap `tools/list`, `resources/list`, and `prompts/list` to at most `n`
570    /// items per response, enabling cursor-based pagination: a response with
571    /// more items remaining includes `"nextCursor"`, an opaque string the
572    /// client echoes back as `params.cursor` on its next call to get the next
573    /// page. `n` is clamped to a minimum of `1`.
574    ///
575    /// Without calling this, every registered tool/resource/prompt is
576    /// returned in a single response — the default, and the only behavior
577    /// before pagination existed.
578    ///
579    /// ```rust
580    /// use rust_web_server::mcp::McpServer;
581    ///
582    /// let server = McpServer::new("my-server", "1.0").page_size(50);
583    /// ```
584    pub fn page_size(mut self, n: usize) -> Self {
585        self.page_size = Some(n.max(1));
586        self
587    }
588
589    /// Push a JSON-RPC notification (no `id` — fire-and-forget, per the
590    /// spec) to every client currently connected to the `GET /mcp` SSE
591    /// stream, framed as an SSE `data:` event.
592    ///
593    /// `params_json`, if given, must already be a valid JSON value (usually
594    /// an object) — it's spliced in verbatim, not escaped or re-serialized.
595    ///
596    /// Never blocks: a client whose event buffer is full (not reading fast
597    /// enough) is treated the same as a disconnected one and dropped from
598    /// the broadcast list, same as `notify` would drop it anyway.
599    ///
600    /// ```rust
601    /// use rust_web_server::mcp::McpServer;
602    ///
603    /// let server = McpServer::new("my-server", "1.0");
604    /// server.notify("notifications/message", Some(r#"{"level":"info","data":"hello"}"#));
605    /// ```
606    pub fn notify(&self, method: &str, params_json: Option<&str>) {
607        let json = render_notification(method, params_json);
608        broadcast_sse_to(&self.sse_clients, &json);
609    }
610
611    /// Handle `GET /mcp`: register a new SSE client and return a
612    /// `text/event-stream` response that streams from its channel until the
613    /// connection closes. See the module docs' SSE section for the wire
614    /// details (keep-alive interval, backpressure behavior).
615    fn start_sse_stream(&self) -> Response {
616        let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(SSE_CHANNEL_CAPACITY);
617        self.sse_clients.lock().unwrap().push(tx);
618
619        let mut response = Response::new();
620        response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
621        response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
622        response.headers.push(Header {
623            name: Header::_CONTENT_TYPE.to_string(),
624            value: "text/event-stream".to_string(),
625        });
626        response.headers.push(Header {
627            name: Header::_CACHE_CONTROL.to_string(),
628            value: "no-cache".to_string(),
629        });
630        response.headers.push(Header {
631            name: "X-Accel-Buffering".to_string(),
632            value: "no".to_string(),
633        });
634        response.stream_pipe = Some(Box::new(SseChannelReader::new(rx)));
635        response
636    }
637
638    /// Advertise the `logging` capability (`"logging":{}`) in `initialize`'s
639    /// response, so clients know they can call `logging/setLevel` and expect
640    /// `notifications/message` log entries over the `GET /mcp` SSE stream.
641    ///
642    /// This only changes what's *advertised* — [`Self::log`] pushes log
643    /// entries regardless of whether this was called, same as [`Self::notify`]
644    /// works unconditionally. A spec-honest client simply wouldn't send
645    /// `logging/setLevel` in the first place without seeing the capability.
646    ///
647    /// ```rust
648    /// use rust_web_server::mcp::McpServer;
649    ///
650    /// let server = McpServer::new("my-server", "1.0").logging_enabled();
651    /// ```
652    pub fn logging_enabled(mut self) -> Self {
653        self.logging_enabled = true;
654        self
655    }
656
657    /// Push a `notifications/message` log entry to every client connected to
658    /// the `GET /mcp` SSE stream, if `level` is at or above the level most
659    /// recently set via a client's `logging/setLevel` request (or
660    /// [`LogLevel::Debug`] — i.e. every level — if none has been set yet).
661    ///
662    /// `data_json` must already be a valid JSON value (the spec allows any
663    /// type here, not just an object — a plain string is fine) — it's
664    /// spliced in verbatim, not escaped or re-serialized. `logger`, if
665    /// given, identifies the log's source (e.g. a module or subsystem name)
666    /// and is escaped automatically.
667    ///
668    /// ```rust
669    /// use rust_web_server::mcp::{LogLevel, McpServer};
670    ///
671    /// let server = McpServer::new("my-server", "1.0").logging_enabled();
672    /// server.log(LogLevel::Warning, Some("database"), r#""connection pool exhausted""#);
673    /// ```
674    pub fn log(&self, level: LogLevel, logger: Option<&str>, data_json: &str) {
675        if level < *self.min_log_level.lock().unwrap() {
676            return;
677        }
678        let logger_field = match logger {
679            Some(l) => format!(r#","logger":"{}""#, json_escape(l)),
680            None => String::new(),
681        };
682        let params = format!(r#"{{"level":"{}"{logger_field},"data":{data_json}}}"#, level.as_str());
683        self.notify("notifications/message", Some(&params));
684    }
685
686    /// Handle `logging/setLevel`: store the requested minimum level so
687    /// subsequent [`Self::log`] calls filter against it. Returns
688    /// `INVALID_PARAMS` for a missing or unrecognized `params.level`.
689    fn do_set_log_level(&self, body: &str) -> Result<String, (i32, String)> {
690        let params = json_rpc::extract_raw(body, "params")
691            .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
692        let level_str = json_rpc::extract_str(&params, "level")
693            .ok_or((json_rpc::INVALID_PARAMS, "Missing level".to_string()))?;
694        let level = LogLevel::parse(&level_str)
695            .ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Unknown log level: {level_str}")))?;
696        *self.min_log_level.lock().unwrap() = level;
697        Ok("{}".to_string())
698    }
699
700    // ── dynamic registration ──────────────────────────────────────────────────
701    //
702    // Unlike `.tool()`/`.resource()`/`.prompt()` (consuming builders, called
703    // before the server starts serving requests), these take `&self` and can
704    // be called at any time from any thread holding a clone of this
705    // `McpServer` — e.g. after discovering a plugin, connecting to a
706    // database, or reacting to a hot-reloaded config file. Every clone
707    // shares the same underlying `Arc<RwLock<Vec<_>>>`, so a mutation made
708    // through one clone is immediately visible to every other clone,
709    // including the ones handling concurrent requests on other threads.
710    //
711    // Each registration/removal pushes the corresponding
712    // `notifications/{tools,resources,prompts}/list_changed` event (no
713    // params, per spec) to every `GET /mcp` SSE client via `.notify()`.
714
715    /// Register a callable tool at runtime, exactly like [`Self::tool`] but
716    /// without needing to own the server (and usable after it's already
717    /// serving requests). Pushes `notifications/tools/list_changed`.
718    ///
719    /// ```rust
720    /// use rust_web_server::mcp::{McpContent, McpServer};
721    ///
722    /// let server = McpServer::new("my-server", "1.0");
723    ///
724    /// // Later, from any thread holding a clone of `server`:
725    /// server.register_tool("refresh_cache", "Reload the in-memory cache", "{}", |_args| {
726    ///     Ok(McpContent::text("cache refreshed"))
727    /// });
728    /// let existed = server.remove_tool("refresh_cache");
729    /// assert!(existed);
730    /// ```
731    pub fn register_tool<F>(&self, name: &str, description: &str, input_schema: &str, handler: F)
732    where
733        F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
734    {
735        self.tools.write().unwrap().push(ToolDef {
736            name: name.to_string(),
737            description: description.to_string(),
738            input_schema: input_schema.to_string(),
739            annotations: None,
740            handler: Arc::new(move |_ctx: McpContext, args: &str| handler(args)),
741        });
742        self.notify("notifications/tools/list_changed", None);
743    }
744
745    /// Remove a previously-registered tool by name. Returns `true` if a tool
746    /// with that name existed and was removed. Pushes
747    /// `notifications/tools/list_changed` only when something was actually
748    /// removed.
749    pub fn remove_tool(&self, name: &str) -> bool {
750        let removed = {
751            let mut tools = self.tools.write().unwrap();
752            let before = tools.len();
753            tools.retain(|t| t.name != name);
754            tools.len() != before
755        };
756        if removed {
757            self.notify("notifications/tools/list_changed", None);
758        }
759        removed
760    }
761
762    /// Register a readable resource at runtime, exactly like [`Self::resource`].
763    /// Pushes `notifications/resources/list_changed`.
764    pub fn register_resource<F>(&self, uri_template: &str, name: &str, description: &str, handler: F)
765    where
766        F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
767    {
768        self.resources.write().unwrap().push(ResourceDef {
769            uri_template: uri_template.to_string(),
770            name: name.to_string(),
771            description: description.to_string(),
772            handler: Arc::new(handler),
773        });
774        self.notify("notifications/resources/list_changed", None);
775    }
776
777    /// Remove a previously-registered resource by its exact `uri_template`
778    /// (the same string passed to [`Self::register_resource`]/[`Self::resource`],
779    /// not a concrete URI). Returns `true` if it existed. Pushes
780    /// `notifications/resources/list_changed` only when something was removed.
781    pub fn remove_resource(&self, uri_template: &str) -> bool {
782        let removed = {
783            let mut resources = self.resources.write().unwrap();
784            let before = resources.len();
785            resources.retain(|r| r.uri_template != uri_template);
786            resources.len() != before
787        };
788        if removed {
789            self.notify("notifications/resources/list_changed", None);
790        }
791        removed
792    }
793
794    /// Register a prompt template at runtime, exactly like [`Self::prompt`]
795    /// (no argument definitions — use [`Self::remove_prompt`] +
796    /// [`Self::register_prompt`] if you need to change a prompt's arguments
797    /// later; there is no dynamic equivalent of [`Self::prompt_with_args`]).
798    /// Pushes `notifications/prompts/list_changed`.
799    pub fn register_prompt<F>(&self, name: &str, description: &str, handler: F)
800    where
801        F: Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync + 'static,
802    {
803        self.prompts.write().unwrap().push(PromptDef {
804            name: name.to_string(),
805            description: description.to_string(),
806            arguments: vec![],
807            handler: Arc::new(handler),
808        });
809        self.notify("notifications/prompts/list_changed", None);
810    }
811
812    /// Remove a previously-registered prompt by name. Returns `true` if it
813    /// existed. Pushes `notifications/prompts/list_changed` only when
814    /// something was removed.
815    pub fn remove_prompt(&self, name: &str) -> bool {
816        let removed = {
817            let mut prompts = self.prompts.write().unwrap();
818            let before = prompts.len();
819            prompts.retain(|p| p.name != name);
820            prompts.len() != before
821        };
822        if removed {
823            self.notify("notifications/prompts/list_changed", None);
824        }
825        removed
826    }
827
828    /// Require a bearer token on every request to the MCP endpoint.
829    ///
830    /// The client must send `Authorization: Bearer <token>`. Requests with a
831    /// missing or wrong token receive `401 Unauthorized` before any JSON-RPC
832    /// processing occurs.
833    ///
834    /// Store the token in an environment variable — never hard-code it:
835    ///
836    /// ```rust,no_run
837    /// use rust_web_server::app::App;
838    /// use rust_web_server::core::New;
839    ///
840    /// let app = App::new()
841    ///     .mcp("my-server", "1.0")
842    ///     .require_bearer(std::env::var("MCP_TOKEN").expect("MCP_TOKEN not set"));
843    /// ```
844    ///
845    /// Claude Desktop config:
846    /// ```json
847    /// { "mcpServers": { "my-server": {
848    ///     "url": "http://localhost:7878/mcp",
849    ///     "headers": { "Authorization": "Bearer <token>" }
850    /// }}}
851    /// ```
852    pub fn require_bearer(mut self, token: impl Into<String>) -> Self {
853        self.auth_token = Some(token.into());
854        self
855    }
856
857    /// Wrap an existing [`Application`] so that non-MCP requests are forwarded
858    /// to it instead of the built-in [`App`].
859    ///
860    /// Use this when your existing server has custom routes, state, or
861    /// middleware that you want to keep alongside the MCP endpoint:
862    ///
863    /// ```rust,no_run
864    /// use rust_web_server::app::App;
865    /// use rust_web_server::mcp::{McpServer, McpContent};
866    /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
867    /// use rust_web_server::test_client::TestClient;
868    ///
869    /// let existing_app = App::with_state(42u32)
870    ///     .get("/api/hello", |_req, _params, _conn, _state| {
871    ///         Response::get_response(&STATUS_CODE_REASON_PHRASE.n200_ok, None, None)
872    ///     });
873    ///
874    /// let server = McpServer::new("my-app", "1.0")
875    ///     .tool("ping", "Ping", "{}", |_| Ok(McpContent::text("pong")))
876    ///     .wrap(existing_app);
877    ///
878    /// // Both /mcp and /api/hello are now handled by the same server.
879    /// let client = TestClient::new(server);
880    /// ```
881    pub fn wrap(mut self, app: impl Application + Send + Sync + 'static) -> Self {
882        self.fallback = Some(Arc::new(app));
883        self
884    }
885
886    /// Override the HTTP path for the MCP endpoint (default `"/mcp"`).
887    pub fn at(mut self, path: impl Into<String>) -> Self {
888        self.path = path.into();
889        self
890    }
891
892    /// Register a callable tool.
893    ///
894    /// - `name` — tool identifier (snake_case recommended)
895    /// - `description` — human-readable description shown to the AI
896    /// - `input_schema` — JSON Schema object for the tool's arguments
897    /// - `handler` — closure receiving the raw `arguments` JSON string
898    ///
899    /// The handler returns [`McpContent`] on success or an error string.  An
900    /// error is returned to the client as `isError: true` (not a protocol error).
901    ///
902    /// Use [`Self::tool_with_context`] instead if the handler needs the
903    /// caller's identity, session, or headers.
904    pub fn tool<F>(self, name: &str, description: &str, input_schema: &str, handler: F) -> Self
905    where
906        F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
907    {
908        self.tools.write().unwrap().push(ToolDef {
909            name: name.to_string(),
910            description: description.to_string(),
911            input_schema: input_schema.to_string(),
912            annotations: None,
913            handler: Arc::new(move |_ctx: McpContext, args: &str| handler(args)),
914        });
915        self
916    }
917
918    /// Register a callable tool with [`ToolAnnotations`] — behavioral hints
919    /// (read-only, destructive, idempotent, open-world) that MCP clients use
920    /// to decide whether to warn or confirm before calling it. Otherwise
921    /// identical to [`Self::tool`] — the handler still only receives
922    /// `arguments`, not [`McpContext`] (there is currently no single builder
923    /// combining annotations with per-request context; call [`Self::tool_with_context`]
924    /// instead if you need context and don't need annotations).
925    ///
926    /// ```rust,no_run
927    /// use rust_web_server::mcp::{McpContent, McpServer, ToolAnnotations};
928    ///
929    /// let server = McpServer::new("my-server", "1.0")
930    ///     .tool_annotated(
931    ///         "delete_file",
932    ///         "Delete a file from disk",
933    ///         r#"{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"#,
934    ///         ToolAnnotations {
935    ///             destructive_hint: Some(true),
936    ///             read_only_hint: Some(false),
937    ///             idempotent_hint: Some(true), // deleting twice = deleting once
938    ///             ..Default::default()
939    ///         },
940    ///         |_args| Ok(McpContent::text("deleted")),
941    ///     );
942    /// ```
943    pub fn tool_annotated<F>(
944        self,
945        name: &str,
946        description: &str,
947        input_schema: &str,
948        annotations: ToolAnnotations,
949        handler: F,
950    ) -> Self
951    where
952        F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
953    {
954        self.tools.write().unwrap().push(ToolDef {
955            name: name.to_string(),
956            description: description.to_string(),
957            input_schema: input_schema.to_string(),
958            annotations: Some(annotations),
959            handler: Arc::new(move |_ctx: McpContext, args: &str| handler(args)),
960        });
961        self
962    }
963
964    /// Register a callable tool whose handler also receives [`McpContext`] —
965    /// caller identity/session info derived from this request's headers and
966    /// whatever `clientInfo` this session sent at `initialize` time.
967    ///
968    /// Same `name`/`description`/`input_schema` semantics as [`Self::tool`];
969    /// the only difference is the handler's first parameter.
970    ///
971    /// ```rust,no_run
972    /// use rust_web_server::mcp::{McpContent, McpServer};
973    ///
974    /// let server = McpServer::new("my-server", "1.0")
975    ///     .tool_with_context(
976    ///         "whoami",
977    ///         "Report the caller's client info",
978    ///         "{}",
979    ///         |ctx, _args| {
980    ///             let name = ctx.client_name.as_deref().unwrap_or("unknown client");
981    ///             Ok(McpContent::text(format!("Called by {name}")))
982    ///         },
983    ///     );
984    /// ```
985    pub fn tool_with_context<F>(self, name: &str, description: &str, input_schema: &str, handler: F) -> Self
986    where
987        F: Fn(McpContext, &str) -> Result<McpContent, String> + Send + Sync + 'static,
988    {
989        self.tools.write().unwrap().push(ToolDef {
990            name: name.to_string(),
991            description: description.to_string(),
992            input_schema: input_schema.to_string(),
993            annotations: None,
994            handler: Arc::new(handler),
995        });
996        self
997    }
998
999    /// Register a readable resource.
1000    ///
1001    /// `uri_template` uses `{param}` placeholders, e.g. `"user://{id}"`.
1002    /// The handler receives the full concrete URI string.
1003    pub fn resource<F>(self, uri_template: &str, name: &str, description: &str, handler: F) -> Self
1004    where
1005        F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
1006    {
1007        self.resources.write().unwrap().push(ResourceDef {
1008            uri_template: uri_template.to_string(),
1009            name: name.to_string(),
1010            description: description.to_string(),
1011            handler: Arc::new(handler),
1012        });
1013        self
1014    }
1015
1016    /// Register a prompt template.
1017    ///
1018    /// The handler receives the raw `arguments` JSON string and returns a
1019    /// list of [`PromptMessage`] values.
1020    pub fn prompt<F>(self, name: &str, description: &str, handler: F) -> Self
1021    where
1022        F: Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync + 'static,
1023    {
1024        self.prompts.write().unwrap().push(PromptDef {
1025            name: name.to_string(),
1026            description: description.to_string(),
1027            arguments: vec![],
1028            handler: Arc::new(handler),
1029        });
1030        self
1031    }
1032
1033    /// Register a prompt template with explicit argument definitions.
1034    pub fn prompt_with_args<F>(
1035        self,
1036        name: &str,
1037        description: &str,
1038        args: Vec<PromptArgDef>,
1039        handler: F,
1040    ) -> Self
1041    where
1042        F: Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync + 'static,
1043    {
1044        self.prompts.write().unwrap().push(PromptDef {
1045            name: name.to_string(),
1046            description: description.to_string(),
1047            arguments: args,
1048            handler: Arc::new(handler),
1049        });
1050        self
1051    }
1052
1053    // ── request dispatch ──────────────────────────────────────────────────────
1054
1055    /// Process a raw JSON-RPC body and return an HTTP response.
1056    ///
1057    /// Equivalent to [`Self::handle_request_with_context`] with an empty
1058    /// [`McpContext`] — tool handlers registered via
1059    /// [`Self::tool_with_context`] will see every field as `None`. Prefer
1060    /// calling through [`Application::execute`] (i.e. actually serving HTTP
1061    /// requests) when you need real per-request context; this method exists
1062    /// for calling the JSON-RPC layer directly, e.g. in tests.
1063    pub fn handle_request(&self, body: &str) -> Response {
1064        self.handle_request_with_context(body, McpContext::default())
1065    }
1066
1067    /// Process a raw JSON-RPC body with an explicit [`McpContext`] and return
1068    /// an HTTP response. [`Self::execute`] calls this with a context built
1069    /// from the request's headers and this session's stored `clientInfo`;
1070    /// [`Self::handle_request`] calls this with an empty context.
1071    ///
1072    /// On a successful `initialize`, this mints a new session id (reusing
1073    /// [`crate::request_id::generate_request_id`]'s ID generator), records
1074    /// `params.clientInfo` under it, and returns the id in an
1075    /// `Mcp-Session-Id` response header — the client is expected to echo that
1076    /// header back on subsequent requests so later `tools/call`s in the same
1077    /// session can look their `clientInfo` back up.
1078    pub fn handle_request_with_context(&self, body: &str, ctx: McpContext) -> Response {
1079        let trimmed = body.trim_start();
1080        if trimmed.starts_with('[') {
1081            return self.handle_batch(trimmed, ctx);
1082        }
1083
1084        let method = match json_rpc::extract_str(body, "method") {
1085            Some(m) => m,
1086            None => return rpc_error(None, json_rpc::INVALID_REQUEST, "Missing method"),
1087        };
1088
1089        let id = json_rpc::extract_id(body);
1090
1091        // Notifications have no `id` — acknowledge with 202 and no body.
1092        if method == "notifications/initialized" || (id.is_none() && method != "ping") {
1093            return no_content();
1094        }
1095
1096        let result = self.dispatch(&method, body, ctx);
1097        let id_str = id.as_deref().unwrap_or("null");
1098        let is_ok = result.is_ok();
1099
1100        let mut response = json_response(&Self::format_result(id_str, &result));
1101
1102        if method == "initialize" && is_ok {
1103            self.start_session(body, &mut response);
1104        }
1105
1106        response
1107    }
1108
1109    /// Process a JSON-RPC 2.0 batch request — a top-level JSON array of
1110    /// request objects sent in a single `POST /mcp` body, per the JSON-RPC
1111    /// batch spec that MCP inherits. Each element is dispatched exactly as
1112    /// [`Self::handle_request_with_context`] would dispatch it standalone;
1113    /// notifications (no `id`) contribute no entry to the response array,
1114    /// same as they'd get no response body outside a batch.
1115    ///
1116    /// An empty array (`[]`) is itself an invalid request per the JSON-RPC
1117    /// spec, so it gets one `Invalid Request` error object back rather than
1118    /// an empty array. A batch made up entirely of notifications produces no
1119    /// response body at all (`202 Accepted`), matching a single notification.
1120    ///
1121    /// If the batch contains a successful `initialize` call, the *first* one
1122    /// mints a session and attaches `Mcp-Session-Id` to the overall response,
1123    /// same as a standalone `initialize` would — sending more than one
1124    /// `initialize` in a batch is unusual and only the first is honored for
1125    /// session purposes, since one HTTP response can only carry one session id.
1126    fn handle_batch(&self, array_body: &str, ctx: McpContext) -> Response {
1127        let elements = json_rpc::split_array_elements(array_body);
1128        if elements.is_empty() {
1129            return rpc_error(None, json_rpc::INVALID_REQUEST, "Invalid Request");
1130        }
1131
1132        let mut parts: Vec<String> = Vec::new();
1133        let mut session_init_body: Option<String> = None;
1134
1135        for elem in &elements {
1136            let method = match json_rpc::extract_str(elem, "method") {
1137                Some(m) => m,
1138                None => {
1139                    parts.push(Self::format_result(
1140                        "null",
1141                        &Err((json_rpc::INVALID_REQUEST, "Missing method".to_string())),
1142                    ));
1143                    continue;
1144                }
1145            };
1146
1147            let id = json_rpc::extract_id(elem);
1148
1149            if method == "notifications/initialized" || (id.is_none() && method != "ping") {
1150                continue;
1151            }
1152
1153            let result = self.dispatch(&method, elem, ctx.clone());
1154            let id_str = id.as_deref().unwrap_or("null");
1155            let is_ok = result.is_ok();
1156
1157            if method == "initialize" && is_ok && session_init_body.is_none() {
1158                session_init_body = Some(elem.clone());
1159            }
1160
1161            parts.push(Self::format_result(id_str, &result));
1162        }
1163
1164        if parts.is_empty() {
1165            // Every element was a notification — no response body, same as a
1166            // single standalone notification.
1167            return no_content();
1168        }
1169
1170        let mut response = json_response(&format!("[{}]", parts.join(",")));
1171        if let Some(init_body) = session_init_body {
1172            self.start_session(&init_body, &mut response);
1173        }
1174        response
1175    }
1176
1177    /// Dispatch one already-parsed JSON-RPC `method` against `body` (the raw
1178    /// single-object message, whether it arrived standalone or as one element
1179    /// of a batch) and return the JSON-RPC `result` payload or an error.
1180    /// Shared by [`Self::handle_request_with_context`] and [`Self::handle_batch`]
1181    /// so the method table exists in exactly one place.
1182    fn dispatch(&self, method: &str, body: &str, ctx: McpContext) -> Result<String, (i32, String)> {
1183        match method {
1184            "initialize"     => self.do_initialize(body),
1185            "ping"           => Ok("{}".to_string()),
1186            "tools/list"     => self.do_tools_list(body),
1187            "tools/call"     => self.do_tools_call(body, ctx),
1188            "resources/list" => self.do_resources_list(body),
1189            "resources/read" => self.do_resources_read(body),
1190            "prompts/list"   => self.do_prompts_list(body),
1191            "prompts/get"    => self.do_prompts_get(body),
1192            "logging/setLevel" => self.do_set_log_level(body),
1193            _                => Err((json_rpc::METHOD_NOT_FOUND, format!("Unknown method: {method}"))),
1194        }
1195    }
1196
1197    /// Render one JSON-RPC 2.0 response object — `{"jsonrpc":"2.0","result":...,"id":...}`
1198    /// or the `error` shape — from a dispatch result and its request's `id` (already
1199    /// rendered as a raw JSON token, e.g. `"1"`, `"\"abc\""`, or `"null"`).
1200    fn format_result(id_str: &str, result: &Result<String, (i32, String)>) -> String {
1201        match result {
1202            Ok(result_json) => format!(
1203                r#"{{"jsonrpc":"2.0","result":{result_json},"id":{id_str}}}"#
1204            ),
1205            Err((code, msg)) => {
1206                let escaped = json_escape(msg);
1207                format!(
1208                    r#"{{"jsonrpc":"2.0","error":{{"code":{code},"message":"{escaped}"}},"id":{id_str}}}"#
1209                )
1210            }
1211        }
1212    }
1213
1214    /// Mint a new session id, record `body`'s `params.clientInfo` under it
1215    /// (logging the caller's identity), and attach the id to `response` as
1216    /// an `Mcp-Session-Id` header. Called once, from
1217    /// [`Self::handle_request_with_context`], right after a successful
1218    /// `initialize`.
1219    fn start_session(&self, body: &str, response: &mut Response) {
1220        let client_info = json_rpc::extract_raw(body, "params")
1221            .and_then(|p| json_rpc::extract_raw(&p, "clientInfo"));
1222        let (name, version) = match &client_info {
1223            Some(info) => (
1224                json_rpc::extract_str(info, "name"),
1225                json_rpc::extract_str(info, "version"),
1226            ),
1227            None => (None, None),
1228        };
1229
1230        eprintln!(
1231            "[mcp] initialize from client {} v{}",
1232            name.as_deref().unwrap_or("unknown"),
1233            version.as_deref().unwrap_or("unknown"),
1234        );
1235
1236        let session_id = crate::request_id::generate_request_id();
1237        self.sessions
1238            .lock()
1239            .unwrap()
1240            .insert(session_id.clone(), StoredClientInfo { name, version });
1241
1242        response.headers.push(Header {
1243            name: "Mcp-Session-Id".to_string(),
1244            value: session_id,
1245        });
1246    }
1247
1248    // ── method handlers ───────────────────────────────────────────────────────
1249
1250    /// Handle `initialize`. Per spec, the server must inspect the client's
1251    /// requested `protocolVersion` and respond with the version it actually
1252    /// supports — allowing the client to abort the session if incompatible —
1253    /// rather than blindly echoing `PROTOCOL_VERSION` regardless of what was
1254    /// asked for.
1255    ///
1256    /// This server implements exactly one protocol version, so "negotiation"
1257    /// here means: if the client asked for that same version, confirm it;
1258    /// otherwise tell the client the version we actually speak (older *or*
1259    /// newer than what was requested), which is always the lower of the two
1260    /// — version strings are `YYYY-MM-DD` dates, so a plain string comparison
1261    /// already orders them correctly with no date parsing needed.
1262    ///
1263    /// `clientInfo` is *not* handled here — [`Self::handle_request_with_context`]
1264    /// extracts and stores it (under a freshly minted session id) after this
1265    /// returns, so it's only ever parsed out of `body` once per call.
1266    fn do_initialize(&self, body: &str) -> Result<String, (i32, String)> {
1267        let params = json_rpc::extract_raw(body, "params");
1268
1269        let client_version = params.as_deref().and_then(|p| json_rpc::extract_str(p, "protocolVersion"));
1270
1271        let negotiated_version: &str = match client_version.as_deref() {
1272            Some(v) if v < PROTOCOL_VERSION => v,
1273            _ => PROTOCOL_VERSION,
1274        };
1275
1276        let logging_cap = if self.logging_enabled { r#","logging":{}"# } else { "" };
1277        // listChanged is always true: register_tool/remove_tool (and the resource/prompt
1278        // equivalents) are always available, unlike logging which is opt-in via
1279        // .logging_enabled(). resources.subscribe stays false — resources/subscribe and
1280        // resources/unsubscribe aren't implemented (that's MCP_TODO.md's TODO-14), so
1281        // advertising it would let a client call a method that doesn't exist.
1282        let caps = format!(
1283            r#"{{"tools":{{"listChanged":true}},"resources":{{"subscribe":false,"listChanged":true}},"prompts":{{"listChanged":true}}{logging_cap}}}"#
1284        );
1285        Ok(format!(
1286            r#"{{"protocolVersion":"{}","capabilities":{caps},"serverInfo":{{"name":"{}","version":"{}"}}}}"#,
1287            json_escape(negotiated_version),
1288            json_escape(&self.server_name),
1289            json_escape(&self.server_version),
1290        ))
1291    }
1292
1293    fn do_tools_list(&self, body: &str) -> Result<String, (i32, String)> {
1294        let items: Vec<String> = self.tools.read().unwrap().iter().map(|t| {
1295            let annotations = match t.annotations {
1296                Some(a) => format!(r#","annotations":{}"#, a.to_json()),
1297                None => String::new(),
1298            };
1299            format!(
1300                r#"{{"name":"{}","description":"{}","inputSchema":{}{}}}"#,
1301                json_escape(&t.name),
1302                json_escape(&t.description),
1303                t.input_schema,
1304                annotations,
1305            )
1306        }).collect();
1307        let (page, next_cursor) = self.paginate(&items, body)?;
1308        Ok(format!(r#"{{"tools":[{}]{}}}"#, page.join(","), next_cursor_json(&next_cursor)))
1309    }
1310
1311    fn do_tools_call(&self, body: &str, ctx: McpContext) -> Result<String, (i32, String)> {
1312        let params = json_rpc::extract_raw(body, "params")
1313            .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
1314        let name = json_rpc::extract_str(&params, "name")
1315            .ok_or((json_rpc::INVALID_PARAMS, "Missing tool name".to_string()))?;
1316        let args = json_rpc::extract_raw(&params, "arguments")
1317            .unwrap_or_else(|| "{}".to_string());
1318
1319        // `_meta.progressToken` (string or number, per spec) — stored raw so
1320        // `McpContext::report_progress` can splice it back verbatim.
1321        let progress_token = json_rpc::extract_raw(&params, "_meta")
1322            .and_then(|meta| json_rpc::extract_raw(&meta, "progressToken"));
1323        let ctx = McpContext { progress_token, ..ctx };
1324
1325        let handler = {
1326            let tools = self.tools.read().unwrap();
1327            tools.iter().find(|t| t.name == name).map(|t| t.handler.clone())
1328        }.ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Unknown tool: {name}")))?;
1329
1330        match handler(ctx, &args) {
1331            Ok(c) => Ok(format!(
1332                r#"{{"content":[{}],"isError":false}}"#,
1333                c.to_content_json(),
1334            )),
1335            Err(e) => {
1336                let escaped = json_escape(&e);
1337                Ok(format!(
1338                    r#"{{"content":[{{"type":"text","text":"{escaped}"}}],"isError":true}}"#
1339                ))
1340            }
1341        }
1342    }
1343
1344    fn do_resources_list(&self, body: &str) -> Result<String, (i32, String)> {
1345        let items: Vec<String> = self.resources.read().unwrap().iter().map(|r| {
1346            format!(
1347                r#"{{"uri":"{}","name":"{}","description":"{}","mimeType":"text/plain"}}"#,
1348                json_escape(&r.uri_template),
1349                json_escape(&r.name),
1350                json_escape(&r.description),
1351            )
1352        }).collect();
1353        let (page, next_cursor) = self.paginate(&items, body)?;
1354        Ok(format!(r#"{{"resources":[{}]{}}}"#, page.join(","), next_cursor_json(&next_cursor)))
1355    }
1356
1357    fn do_resources_read(&self, body: &str) -> Result<String, (i32, String)> {
1358        let params = json_rpc::extract_raw(body, "params")
1359            .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
1360        let uri = json_rpc::extract_str(&params, "uri")
1361            .ok_or((json_rpc::INVALID_PARAMS, "Missing uri".to_string()))?;
1362
1363        let handler = {
1364            let resources = self.resources.read().unwrap();
1365            resources.iter().find(|r| uri_matches(&r.uri_template, &uri)).map(|r| r.handler.clone())
1366        }.ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Resource not found: {uri}")))?;
1367
1368        match handler(&uri) {
1369            Ok(c) => {
1370                let text_esc = json_escape(&c.text);
1371                let uri_esc  = json_escape(&uri);
1372                Ok(format!(
1373                    r#"{{"contents":[{{"uri":"{uri_esc}","mimeType":"{}","text":"{text_esc}"}}]}}"#,
1374                    c.mime(),
1375                ))
1376            }
1377            Err(e) => Err((json_rpc::INVALID_PARAMS, e)),
1378        }
1379    }
1380
1381    fn do_prompts_list(&self, body: &str) -> Result<String, (i32, String)> {
1382        let items: Vec<String> = self.prompts.read().unwrap().iter().map(|p| {
1383            let arg_defs: Vec<String> = p.arguments.iter().map(|a| {
1384                format!(
1385                    r#"{{"name":"{}","description":"{}","required":{}}}"#,
1386                    json_escape(&a.name),
1387                    json_escape(&a.description),
1388                    a.required,
1389                )
1390            }).collect();
1391            format!(
1392                r#"{{"name":"{}","description":"{}","arguments":[{}]}}"#,
1393                json_escape(&p.name),
1394                json_escape(&p.description),
1395                arg_defs.join(","),
1396            )
1397        }).collect();
1398        let (page, next_cursor) = self.paginate(&items, body)?;
1399        Ok(format!(r#"{{"prompts":[{}]{}}}"#, page.join(","), next_cursor_json(&next_cursor)))
1400    }
1401
1402    /// Slice `items` (already-rendered JSON object strings for one
1403    /// `*/list` response) according to [`Self::page_size`] and this
1404    /// request's `params.cursor`, returning the page and — if more items
1405    /// remain — the opaque `nextCursor` to embed in the response.
1406    ///
1407    /// Without a configured `page_size`, always returns every item and no
1408    /// cursor, i.e. pagination is fully opt-in.
1409    fn paginate<'a>(&self, items: &'a [String], body: &str) -> Result<(&'a [String], Option<String>), (i32, String)> {
1410        let page_size = match self.page_size {
1411            Some(n) => n,
1412            None => return Ok((items, None)),
1413        };
1414
1415        let cursor = json_rpc::extract_raw(body, "params")
1416            .and_then(|p| json_rpc::extract_str(&p, "cursor"));
1417
1418        let offset = match cursor {
1419            Some(c) => decode_cursor(&c)
1420                .ok_or((json_rpc::INVALID_PARAMS, "Invalid cursor".to_string()))?,
1421            None => 0,
1422        };
1423
1424        if offset >= items.len() {
1425            return Ok((&[], None));
1426        }
1427
1428        let end = (offset + page_size).min(items.len());
1429        let next_cursor = if end < items.len() { Some(encode_cursor(end)) } else { None };
1430        Ok((&items[offset..end], next_cursor))
1431    }
1432
1433    fn do_prompts_get(&self, body: &str) -> Result<String, (i32, String)> {
1434        let params = json_rpc::extract_raw(body, "params")
1435            .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
1436        let name = json_rpc::extract_str(&params, "name")
1437            .ok_or((json_rpc::INVALID_PARAMS, "Missing prompt name".to_string()))?;
1438        let args = json_rpc::extract_raw(&params, "arguments")
1439            .unwrap_or_else(|| "{}".to_string());
1440
1441        let (description, handler) = {
1442            let prompts = self.prompts.read().unwrap();
1443            prompts.iter().find(|p| p.name == name).map(|p| (p.description.clone(), p.handler.clone()))
1444        }.ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Unknown prompt: {name}")))?;
1445
1446        match handler(&args) {
1447            Ok(msgs) => {
1448                let msg_jsons: Vec<String> = msgs.iter().map(|m| m.to_json()).collect();
1449                Ok(format!(
1450                    r#"{{"description":"{}","messages":[{}]}}"#,
1451                    json_escape(&description),
1452                    msg_jsons.join(","),
1453                ))
1454            }
1455            Err(e) => Err((json_rpc::INVALID_PARAMS, e)),
1456        }
1457    }
1458
1459    /// Build the [`McpContext`] for an incoming request: the `Mcp-Session-Id`
1460    /// header, if present, plus whatever `clientInfo` was recorded for that
1461    /// session at `initialize` time (if this session is recognized).
1462    fn context_for(&self, request: &Request) -> McpContext {
1463        let session_id = request
1464            .get_header("Mcp-Session-Id".to_string())
1465            .map(|h| h.value.clone());
1466
1467        let (client_name, client_version) = match &session_id {
1468            Some(sid) => match self.sessions.lock().unwrap().get(sid) {
1469                Some(info) => (info.name.clone(), info.version.clone()),
1470                None => (None, None),
1471            },
1472            None => (None, None),
1473        };
1474
1475        McpContext {
1476            client_name,
1477            client_version,
1478            session_id,
1479            auth_claims: None,
1480            progress_token: None,
1481            sse_clients: Some(self.sse_clients.clone()),
1482        }
1483    }
1484}
1485
1486/// Render one JSON-RPC 2.0 notification (no `id` — fire-and-forget, per
1487/// spec) as an SSE `data:`-ready message body. Shared by [`McpServer::notify`]
1488/// and [`McpContext::report_progress`].
1489fn render_notification(method: &str, params_json: Option<&str>) -> String {
1490    let params_field = match params_json {
1491        Some(p) => format!(r#","params":{p}"#),
1492        None => String::new(),
1493    };
1494    format!(r#"{{"jsonrpc":"2.0","method":"{}"{}}}"#, json_escape(method), params_field)
1495}
1496
1497/// Send a raw pre-built JSON-RPC message to every client in `clients`,
1498/// pruning any whose channel is full or disconnected. Shared by
1499/// [`McpServer::notify`] and [`McpContext::report_progress`] — the latter
1500/// only has a clone of the broadcast list, not a whole `McpServer`.
1501fn broadcast_sse_to(clients: &Arc<Mutex<Vec<SseSender>>>, json: &str) {
1502    let frame = format!("data: {json}\n\n").into_bytes();
1503    let mut clients = clients.lock().unwrap();
1504    clients.retain(|tx| tx.try_send(frame.clone()).is_ok());
1505}
1506
1507// ── SSE channel reader ────────────────────────────────────────────────────────
1508
1509/// Adapts an `mpsc::Receiver<Vec<u8>>` of pre-framed SSE bytes into a
1510/// blocking [`std::io::Read`], so `Server::pipe_stream` (already written for
1511/// proxy passthrough streaming) can drive a `GET /mcp` SSE connection with no
1512/// changes to the server's write loop.
1513///
1514/// Blocks in [`Self::read`] until either a frame arrives, the sender side is
1515/// dropped (all `McpServer` clones gone — EOF, closing the connection), or
1516/// [`SSE_KEEPALIVE_INTERVAL`] elapses with nothing to send (writes a `:
1517/// keep-alive` comment instead, both to satisfy proxies that time out
1518/// silent connections and to surface a dead peer on the next write attempt).
1519struct SseChannelReader {
1520    rx: mpsc::Receiver<Vec<u8>>,
1521    leftover: Vec<u8>,
1522}
1523
1524impl SseChannelReader {
1525    fn new(rx: mpsc::Receiver<Vec<u8>>) -> Self {
1526        SseChannelReader { rx, leftover: Vec::new() }
1527    }
1528}
1529
1530impl std::io::Read for SseChannelReader {
1531    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
1532        if self.leftover.is_empty() {
1533            loop {
1534                match self.rx.recv_timeout(SSE_KEEPALIVE_INTERVAL) {
1535                    Ok(frame) => { self.leftover = frame; break; }
1536                    Err(mpsc::RecvTimeoutError::Timeout) => {
1537                        self.leftover = b": keep-alive\n\n".to_vec();
1538                        break;
1539                    }
1540                    Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(0),
1541                }
1542            }
1543        }
1544
1545        let n = self.leftover.len().min(buf.len());
1546        buf[..n].copy_from_slice(&self.leftover[..n]);
1547        self.leftover.drain(..n);
1548        Ok(n)
1549    }
1550}
1551
1552// ── Application ───────────────────────────────────────────────────────────────
1553
1554impl Application for McpServer {
1555    fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
1556        if request.request_uri == self.path {
1557            // Check bearer token before processing any MCP request.
1558            if let Some(expected) = &self.auth_token {
1559                let provided = request.headers.iter()
1560                    .find(|h| h.name.eq_ignore_ascii_case("authorization"))
1561                    .map(|h| h.value.as_str())
1562                    .unwrap_or("");
1563                let bearer = provided.strip_prefix("Bearer ").unwrap_or("");
1564                if bearer != expected.as_str() {
1565                    return Ok(unauthorized());
1566                }
1567            }
1568
1569            return Ok(match request.method.as_str() {
1570                "POST" => {
1571                    let body = std::str::from_utf8(&request.body).unwrap_or("");
1572                    let ctx = self.context_for(request);
1573                    self.handle_request_with_context(body, ctx)
1574                }
1575                "GET" => self.start_sse_stream(),
1576                "OPTIONS" => {
1577                    // CORS preflight for browser-based MCP clients
1578                    let mut r = Response::new();
1579                    r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
1580                    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
1581                    r.headers.push(Header {
1582                        name: "Allow".to_string(),
1583                        value: "GET, POST, OPTIONS".to_string(),
1584                    });
1585                    r
1586                }
1587                _ => {
1588                    let mut r = Response::new();
1589                    r.status_code = *STATUS_CODE_REASON_PHRASE.n405_method_not_allowed.status_code;
1590                    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n405_method_not_allowed.reason_phrase.to_string();
1591                    r.headers.push(Header {
1592                        name: "Allow".to_string(),
1593                        value: "GET, POST, OPTIONS".to_string(),
1594                    });
1595                    r.content_range_list = vec![Range::get_content_range(
1596                        b"MCP endpoint only accepts GET (SSE) or POST".to_vec(),
1597                        MimeType::TEXT_PLAIN.to_string(),
1598                    )];
1599                    r
1600                }
1601            });
1602        }
1603
1604        // Not an MCP path — fall through to the wrapped app (or built-in App).
1605        match &self.fallback {
1606            Some(app) => app.execute(request, connection),
1607            None      => App::new().execute(request, connection),
1608        }
1609    }
1610}
1611
1612// ── public helper ─────────────────────────────────────────────────────────────
1613
1614/// Extract a string argument from a tool/prompt `arguments` JSON object.
1615///
1616/// ```rust
1617/// use rust_web_server::mcp::extract_arg;
1618/// assert_eq!(extract_arg(r#"{"text":"hello"}"#, "text").as_deref(), Some("hello"));
1619/// assert_eq!(extract_arg(r#"{}"#, "missing"), None);
1620/// ```
1621pub fn extract_arg(arguments: &str, name: &str) -> Option<String> {
1622    json_rpc::extract_str(arguments, name)
1623}
1624
1625// ── internal helpers ──────────────────────────────────────────────────────────
1626
1627fn json_response(body: &str) -> Response {
1628    let mut r = Response::new();
1629    r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
1630    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
1631    r.content_range_list = vec![Range::get_content_range(
1632        body.as_bytes().to_vec(),
1633        MimeType::APPLICATION_JSON.to_string(),
1634    )];
1635    r
1636}
1637
1638fn no_content() -> Response {
1639    let mut r = Response::new();
1640    r.status_code = *STATUS_CODE_REASON_PHRASE.n202_accepted.status_code;
1641    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n202_accepted.reason_phrase.to_string();
1642    r
1643}
1644
1645fn unauthorized() -> Response {
1646    let mut r = Response::new();
1647    r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
1648    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
1649    r.headers.push(Header {
1650        name: "WWW-Authenticate".to_string(),
1651        value: "Bearer".to_string(),
1652    });
1653    r.content_range_list = vec![Range::get_content_range(
1654        b"Unauthorized".to_vec(),
1655        MimeType::TEXT_PLAIN.to_string(),
1656    )];
1657    r
1658}
1659
1660fn rpc_error(id: Option<&str>, code: i32, message: &str) -> Response {
1661    let id_str  = id.unwrap_or("null");
1662    let escaped = json_escape(message);
1663    json_response(&format!(
1664        r#"{{"jsonrpc":"2.0","error":{{"code":{code},"message":"{escaped}"}},"id":{id_str}}}"#
1665    ))
1666}
1667
1668pub(crate) fn json_escape(s: &str) -> String {
1669    let mut out = String::with_capacity(s.len() + 4);
1670    for ch in s.chars() {
1671        match ch {
1672            '"'  => out.push_str("\\\""),
1673            '\\' => out.push_str("\\\\"),
1674            '\n' => out.push_str("\\n"),
1675            '\r' => out.push_str("\\r"),
1676            '\t' => out.push_str("\\t"),
1677            c if (c as u32) < 0x20 => { let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{:04x}", c as u32)); }
1678            c    => out.push(c),
1679        }
1680    }
1681    out
1682}
1683
1684// ── pagination cursors ─────────────────────────────────────────────────────────
1685
1686/// Render `,"nextCursor":"..."` for a `*/list` response, or `""` if there's
1687/// no next page — spliced directly after the closing `]` of the items array.
1688fn next_cursor_json(next_cursor: &Option<String>) -> String {
1689    match next_cursor {
1690        Some(c) => format!(r#","nextCursor":"{}""#, json_escape(c)),
1691        None => String::new(),
1692    }
1693}
1694
1695/// Encode a `tools/list`/`resources/list`/`prompts/list` offset as the
1696/// opaque `nextCursor`/`params.cursor` string the MCP spec expects — just
1697/// base64 of the decimal offset, e.g. `50` → `"NTA="`. Callers only ever
1698/// treat this as opaque; the encoding is a private implementation detail of
1699/// this module, not a client-facing contract.
1700fn encode_cursor(offset: usize) -> String {
1701    base64_encode(offset.to_string().as_bytes())
1702}
1703
1704/// Decode a cursor produced by [`encode_cursor`]. Returns `None` for
1705/// anything that isn't valid base64 of a decimal `usize` — a malformed or
1706/// tampered cursor, not a crash.
1707fn decode_cursor(cursor: &str) -> Option<usize> {
1708    String::from_utf8(base64_decode(cursor)?).ok()?.parse().ok()
1709}
1710
1711const BASE64_TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1712
1713fn base64_encode(data: &[u8]) -> String {
1714    let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
1715    for chunk in data.chunks(3) {
1716        let b0 = chunk[0] as u32;
1717        let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
1718        let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
1719        let n = (b0 << 16) | (b1 << 8) | b2;
1720        out.push(BASE64_TABLE[((n >> 18) & 0x3F) as usize] as char);
1721        out.push(BASE64_TABLE[((n >> 12) & 0x3F) as usize] as char);
1722        out.push(if chunk.len() > 1 { BASE64_TABLE[((n >> 6) & 0x3F) as usize] as char } else { '=' });
1723        out.push(if chunk.len() > 2 { BASE64_TABLE[(n & 0x3F) as usize] as char } else { '=' });
1724    }
1725    out
1726}
1727
1728fn base64_decode(s: &str) -> Option<Vec<u8>> {
1729    fn sextet(c: u8) -> Option<u32> {
1730        match c {
1731            b'A'..=b'Z' => Some((c - b'A') as u32),
1732            b'a'..=b'z' => Some((c - b'a' + 26) as u32),
1733            b'0'..=b'9' => Some((c - b'0' + 52) as u32),
1734            b'+' => Some(62),
1735            b'/' => Some(63),
1736            _ => None,
1737        }
1738    }
1739
1740    let trimmed = s.trim_end_matches('=');
1741    let bytes = trimmed.as_bytes();
1742    let mut out = Vec::with_capacity(bytes.len() * 3 / 4 + 3);
1743    for chunk in bytes.chunks(4) {
1744        if chunk.len() == 1 {
1745            return None; // not a valid base64 length
1746        }
1747        let vals: Vec<u32> = chunk.iter().map(|&b| sextet(b)).collect::<Option<Vec<_>>>()?;
1748        let n = vals.iter().enumerate().fold(0u32, |acc, (i, &v)| acc | (v << (18 - 6 * i)));
1749        out.push(((n >> 16) & 0xFF) as u8);
1750        if vals.len() > 2 { out.push(((n >> 8) & 0xFF) as u8); }
1751        if vals.len() > 3 { out.push((n & 0xFF) as u8); }
1752    }
1753    Some(out)
1754}
1755
1756fn uri_matches(template: &str, uri: &str) -> bool {
1757    // Template `"user://{id}"` matches any URI starting with `"user://"`.
1758    match template.find('{') {
1759        Some(pos) => uri.starts_with(&template[..pos]),
1760        None      => template == uri,
1761    }
1762}