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 specification](https://modelcontextprotocol.io/specification/2025-11-25)
52//! up to 2025-11-25, negotiating 2024-11-05, 2025-03-26, 2025-06-18 or 2025-11-25.
53//!
54//! `GET /mcp` opens a Server-Sent Events stream for server → client push —
55//! see [`McpServer::notify`] and the module docs' SSE section below.
56//!
57//! # SSE streaming transport
58//!
59//! A client that sends `GET /mcp` (instead of `POST`) gets back a
60//! `text/event-stream` response that stays open indefinitely. Call
61//! [`McpServer::notify`] from anywhere (a background thread, another request's
62//! handler, ...) to push a JSON-RPC notification to every currently-connected
63//! SSE client:
64//!
65//! ```rust,no_run
66//! use rust_web_server::mcp::McpServer;
67//!
68//! let server = McpServer::new("my-server", "1.0");
69//! // Elsewhere, e.g. after some background job finishes:
70//! server.notify("notifications/message", Some(r#"{"level":"info","data":"job done"}"#));
71//! ```
72//!
73//! Idle connections receive a `: keep-alive` SSE comment every 15 seconds so
74//! intermediate proxies don't time them out; this doubles as the mechanism
75//! that detects a client has disconnected (the next write attempt fails and
76//! the connection is dropped). A client whose event buffer fills up (32
77//! pending frames, unconsumed) is treated the same as a disconnected one and
78//! dropped from the broadcast list — [`McpServer::notify`] never blocks the
79//! calling thread waiting on a slow reader.
80//!
81//! This transport is only wired up for the plain HTTP/1.1 path
82//! (`Server::run`/`Server::process`) — same scope as `Response::stream_pipe`
83//! generally, which the HTTP/2 (`h2_server`) and HTTP/3 (`h3_handler`)
84//! handlers don't yet support for *any* response, not just this one.
85//!
86//! # Environment variables
87//!
88//! None — configure the server programmatically via the builder.
89
90pub(crate) mod json_rpc;
91pub mod gateway;
92
93#[cfg(test)]
94mod tests;
95
96use std::collections::HashMap;
97#[cfg(feature = "http2")]
98use std::future::Future;
99#[cfg(feature = "http2")]
100use std::pin::Pin;
101use std::sync::mpsc::{self, SyncSender};
102use std::sync::{Arc, Mutex, RwLock};
103use std::time::Duration;
104
105use crate::app::App;
106use crate::application::Application;
107use crate::core::New;
108use crate::header::Header;
109use crate::mime_type::MimeType;
110use crate::range::Range;
111use crate::request::Request;
112use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
113use crate::server::ConnectionInfo;
114
115/// The newest MCP protocol version this server speaks (#3).
116pub(crate) const PROTOCOL_VERSION: &str = "2025-11-25";
117
118/// Every protocol version this server speaks, oldest first.
119pub(crate) const SUPPORTED_PROTOCOL_VERSIONS: [&str; 4] = ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"];
120
121/// The version a request without `MCP-Protocol-Version` is taken to use
122/// (Streamable HTTP transport, 2025-06-18).
123const ASSUMED_PROTOCOL_VERSION: &str = "2025-03-26";
124
125/// The version to answer an `initialize` asking for `requested` with: that
126/// version when this server speaks it, else the newest (the client then
127/// decides whether it can continue).
128pub(crate) fn negotiate_version(requested: Option<&str>) -> &'static str {
129 requested
130 .and_then(|v| SUPPORTED_PROTOCOL_VERSIONS.iter().find(|s| **s == v).copied())
131 .unwrap_or(PROTOCOL_VERSION)
132}
133
134// ── public content types ──────────────────────────────────────────────────────
135
136/// Content returned by tool and resource handlers.
137///
138/// Create with [`McpContent::text`] (plain text or JSON strings),
139/// [`McpContent::json`] (marks MIME type as `application/json`),
140/// [`McpContent::image`] (base64-encoded binary image data), or
141/// [`McpContent::embedded`] (a resource embedded inline in a tool response).
142#[derive(Clone, Debug)]
143pub struct McpContent {
144 /// `"text"`, `"image"`, `"audio"`, `"resource"`, `"blob"`, `"blocks"`
145 /// or `"structured"`.
146 pub kind: &'static str,
147 /// The content string — text for `"text"`, base64 data for `"image"`,
148 /// or the embedded resource's text for `"resource"`.
149 pub text: String,
150 /// Optional MIME type override (default `"text/plain"` for `"text"`;
151 /// required in practice for `"image"`/`"resource"`, set by their
152 /// constructors).
153 pub mime_type: Option<String>,
154 /// The resource URI — only set (and only serialized) for `"resource"`.
155 pub uri: Option<String>,
156}
157
158impl McpContent {
159 /// Plain-text content.
160 pub fn text(s: impl Into<String>) -> Self {
161 McpContent { kind: "text", text: s.into(), mime_type: None, uri: None }
162 }
163
164 /// JSON content — sets `mimeType` to `application/json`.
165 pub fn json(s: impl Into<String>) -> Self {
166 McpContent { kind: "text", text: s.into(), mime_type: Some("application/json".to_string()), uri: None }
167 }
168
169 /// Structured content (MCP 2025-06-18): `json` is a JSON value — usually an
170 /// object matching the tool's [`McpServer::tool_output_schema`] — sent as
171 /// the result's `structuredContent`, and as text for older clients.
172 pub fn structured(json: impl Into<String>) -> Self {
173 McpContent { kind: "structured", text: json.into(), mime_type: Some("application/json".to_string()), uri: None }
174 }
175
176 /// Audio content — `data` is base64-encoded audio and `mime_type` e.g.
177 /// `"audio/wav"` (MCP 2025-03-26).
178 pub fn audio(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
179 McpContent { kind: "audio", text: data.into(), mime_type: Some(mime_type.into()), uri: None }
180 }
181
182 /// A binary resource for `resources/read` — `data` is base64-encoded and
183 /// sent as the contents' `blob` (with `mime_type`) instead of `text`.
184 pub fn blob(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
185 McpContent { kind: "blob", text: data.into(), mime_type: Some(mime_type.into()), uri: None }
186 }
187
188 /// Several content items in one tool result — e.g. a text summary, an
189 /// image and an embedded resource. Each item renders as it would alone.
190 pub fn blocks(items: impl IntoIterator<Item = McpContent>) -> Self {
191 let rendered: Vec<String> = items.into_iter().map(|item| item.to_content_json()).collect();
192 McpContent { kind: "blocks", text: rendered.join(","), mime_type: None, uri: None }
193 }
194
195 /// A complete `tools/call` result (`{"content":[...],...}`), sent as is —
196 /// how the gateway passes an upstream server's result through.
197 pub(crate) fn raw_result(json: impl Into<String>) -> Self {
198 McpContent { kind: "result", text: json.into(), mime_type: Some("application/json".to_string()), uri: None }
199 }
200
201 /// Image content (screenshot, chart, generated art) — `data` is base64-encoded
202 /// binary and `mime_type` is e.g. `"image/png"`.
203 pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
204 McpContent { kind: "image", text: data.into(), mime_type: Some(mime_type.into()), uri: None }
205 }
206
207 /// A resource embedded inline in a tool response, as opposed to one a
208 /// client fetches separately via `resources/read`.
209 pub fn embedded(uri: impl Into<String>, text: impl Into<String>, mime_type: impl Into<String>) -> Self {
210 McpContent { kind: "resource", text: text.into(), mime_type: Some(mime_type.into()), uri: Some(uri.into()) }
211 }
212
213 fn to_content_json(&self) -> String {
214 match self.kind {
215 "image" | "audio" => format!(
216 r#"{{"type":"{}","data":"{}","mimeType":"{}"}}"#,
217 self.kind,
218 json_escape(&self.text),
219 json_escape(self.mime_type.as_deref().unwrap_or("application/octet-stream")),
220 ),
221 "blob" => format!(
222 r#"{{"type":"resource","resource":{{"uri":"{}","mimeType":"{}","blob":"{}"}}}}"#,
223 json_escape(self.uri.as_deref().unwrap_or("")),
224 json_escape(self.mime()),
225 json_escape(&self.text),
226 ),
227 "blocks" => self.text.clone(),
228 "resource" => format!(
229 r#"{{"type":"resource","resource":{{"uri":"{}","mimeType":"{}","text":"{}"}}}}"#,
230 json_escape(self.uri.as_deref().unwrap_or("")),
231 json_escape(self.mime_type.as_deref().unwrap_or("text/plain")),
232 json_escape(&self.text),
233 ),
234 _ => format!(r#"{{"type":"text","text":"{}"}}"#, json_escape(&self.text)),
235 }
236 }
237
238 fn mime(&self) -> &str {
239 self.mime_type.as_deref().unwrap_or("text/plain")
240 }
241}
242
243/// A single message in a prompt response.
244#[derive(Clone, Debug)]
245pub struct PromptMessage {
246 /// `"user"` or `"assistant"`.
247 pub role: &'static str,
248 /// The message content.
249 pub content: McpContent,
250}
251
252impl PromptMessage {
253 /// Build a user-role message.
254 pub fn user(text: impl Into<String>) -> Self {
255 PromptMessage { role: "user", content: McpContent::text(text) }
256 }
257
258 /// Build an assistant-role message.
259 pub fn assistant(text: impl Into<String>) -> Self {
260 PromptMessage { role: "assistant", content: McpContent::text(text) }
261 }
262
263 fn to_json(&self) -> String {
264 format!(
265 r#"{{"role":"{}","content":{}}}"#,
266 self.role,
267 self.content.to_content_json(),
268 )
269 }
270}
271
272/// Argument definition for a prompt template.
273#[derive(Clone)]
274pub struct PromptArgDef {
275 pub name: String,
276 pub description: String,
277 pub required: bool,
278}
279
280impl PromptArgDef {
281 pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
282 PromptArgDef { name: name.into(), description: description.into(), required: true }
283 }
284
285 pub fn optional(name: impl Into<String>, description: impl Into<String>) -> Self {
286 PromptArgDef { name: name.into(), description: description.into(), required: false }
287 }
288}
289
290// ── McpContext ────────────────────────────────────────────────────────────────
291
292/// Per-request context passed to tool handlers registered via
293/// [`McpServer::tool_with_context`] — caller identity and session info that a
294/// plain `Fn(&str) -> ...` tool handler has no way to see.
295///
296/// Constructed in [`McpServer::execute`] from the current request's headers
297/// plus whatever `clientInfo` was recorded for this session at `initialize`
298/// time (see [`McpServer::handle_request_with_context`]).
299#[derive(Debug, Clone, Default)]
300pub struct McpContext {
301 /// `clientInfo.name` sent in this session's `initialize` call, if the
302 /// client sent one and this request carries a recognized `Mcp-Session-Id`.
303 pub client_name: Option<String>,
304 /// `clientInfo.version` sent in this session's `initialize` call, under
305 /// the same conditions as `client_name`.
306 pub client_version: Option<String>,
307 /// The `Mcp-Session-Id` header on this request, if present — the value
308 /// the server minted and returned in the `initialize` response header
309 /// for this session (see the module docs' Sessions section).
310 pub session_id: Option<String>,
311 /// The verified caller's claims as a JSON string: the serialized
312 /// [`crate::sso::OidcClaims`] when [`McpServer::require_oauth`] (`sso`
313 /// feature) is configured and this request's bearer token verified, or
314 /// `{"sub":"<identity>"}` for a token from
315 /// [`McpServer::require_bearer_tokens`]. `None` if OAuth isn't configured, or for a
316 /// hand-built context (e.g. `handle_request` in tests) that bypassed
317 /// `McpServer::execute`'s auth check entirely.
318 pub auth_claims: Option<String>,
319 /// The raw JSON value of `params._meta.progressToken` from the
320 /// triggering `tools/call` request, if the client sent one — a spec
321 /// `string | number`, so this is stored pre-rendered (already correctly
322 /// quoted if it's a string) rather than decoded, and spliced back
323 /// verbatim by [`Self::report_progress`]. `None` for anything other than
324 /// a `tools/call` whose caller asked for progress updates.
325 pub progress_token: Option<String>,
326 /// Shared handle back to the owning [`McpServer`]'s SSE broadcast list,
327 /// used by [`Self::report_progress`]. Not `pub` — this is plumbing, not
328 /// part of the context data a handler reads. `None` for a context built
329 /// by hand (e.g. via [`McpServer::handle_request_with_context`] in a
330 /// test) rather than through [`McpServer::execute`], in which case
331 /// `report_progress` silently no-ops — there's no live server to
332 /// broadcast through.
333 sse_clients: Option<Arc<Mutex<Vec<SseClient>>>>,
334 /// Shared flag flipped by a `notifications/cancelled` referencing this
335 /// `tools/call`'s request id, checked by [`Self::is_cancelled`]. Not
336 /// `pub` — see that method. `None` for anything other than a live
337 /// `tools/call` dispatched through [`McpServer::execute`]/`handle_request_with_context`.
338 cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
339 /// Whether this session's `initialize` call declared
340 /// `params.capabilities.sampling`, checked by [`Self::sample`] before
341 /// sending a request the client never said it could answer.
342 sampling_supported: bool,
343 /// Shared handle to the owning [`McpServer`]'s in-flight
344 /// server-initiated-request registry (`sampling/createMessage`,
345 /// `roots/list`), used by [`Self::sample`]/[`Self::list_roots`]. Not
346 /// `pub` — see `sse_clients` for the same "plumbing, not context data"
347 /// reasoning; `None` under the same conditions.
348 pending_replies: Option<Arc<Mutex<HashMap<String, mpsc::Sender<Result<String, String>>>>>>,
349 /// Whether this session's `initialize` call declared
350 /// `params.capabilities.roots`, checked by [`Self::list_roots`] before
351 /// sending a request the client never said it could answer.
352 roots_supported: bool,
353 /// Shared handle to the owning [`McpServer`]'s session registry, used
354 /// by [`Self::list_roots`] to read/write this session's cached roots
355 /// list. Not `pub` — plumbing, not context data.
356 sessions: Option<Arc<Mutex<HashMap<String, StoredClientInfo>>>>,
357 /// The SSE response of the `tools/call` POST this context serves, when
358 /// the client asked for progress and accepts `text/event-stream`:
359 /// progress notifications go there, ahead of the result.
360 request_stream: Option<SseSender>,
361}
362
363impl McpContext {
364 /// Push a `notifications/progress` event over the SSE channel for this
365 /// request's `progressToken`, if the client asked for progress updates
366 /// (`params._meta.progressToken` on the triggering `tools/call`) and
367 /// this context was built through a live [`McpServer`] (via `execute()`,
368 /// not a bare `McpContext { .. }` — see the `sse_clients` field doc).
369 ///
370 /// Silently does nothing in either case — a handler doesn't need to
371 /// branch on whether progress reporting is actually wired up before
372 /// calling this; it's always safe to call.
373 ///
374 /// `total` and `message` are both optional, matching the spec's
375 /// `notifications/progress` shape: `{"progressToken":...,"progress":...,
376 /// "total":...,"message":"..."}` (with `total`/`message` omitted when not
377 /// given here).
378 ///
379 /// ```rust,no_run
380 /// use rust_web_server::mcp::{McpContent, McpServer};
381 ///
382 /// let server = McpServer::new("my-server", "1.0")
383 /// .tool_with_context("long_job", "Do something slow", "{}", |ctx, _args| {
384 /// ctx.report_progress(0.0, Some(100.0), Some("starting"));
385 /// // ... do work ...
386 /// ctx.report_progress(100.0, Some(100.0), Some("done"));
387 /// Ok(McpContent::text("done"))
388 /// });
389 /// ```
390 pub fn report_progress(&self, progress: f64, total: Option<f64>, message: Option<&str>) {
391 let (Some(token), Some(sse_clients)) = (&self.progress_token, &self.sse_clients) else {
392 return;
393 };
394
395 let total_field = match total {
396 Some(t) => format!(r#","total":{t}"#),
397 None => String::new(),
398 };
399 let message_field = match message {
400 Some(m) => format!(r#","message":"{}""#, json_escape(m)),
401 None => String::new(),
402 };
403 let params = format!(
404 r#"{{"progressToken":{token},"progress":{progress}{total_field}{message_field}}}"#
405 );
406 let json = render_notification("notifications/progress", Some(¶ms));
407 if let Some(stream) = &self.request_stream {
408 let _ = stream.send(format!("data: {json}\n\n").into_bytes());
409 } else if let Some(session) = &self.session_id {
410 send_sse_to_sessions(sse_clients, std::slice::from_ref(session), &json);
411 } else {
412 broadcast_sse_to(sse_clients, &json);
413 }
414 }
415
416 /// `true` if the client sent `notifications/cancelled` referencing this
417 /// `tools/call`'s request id.
418 ///
419 /// Rust cannot forcibly interrupt a running synchronous closure — same
420 /// limitation [`crate::timeout::with_timeout`] documents — so this is
421 /// *cooperative* cancellation: nothing happens automatically. A handler
422 /// that does its own iterative work (processing a batch of items, say)
423 /// can check this between steps and return early; a handler that never
424 /// checks it runs to completion regardless, exactly as before this
425 /// existed.
426 ///
427 /// Always `false` if the client never sent a cancellation, if this
428 /// wasn't dispatched as a `tools/call` (the only method cancellation
429 /// applies to), or if `ctx` was built without a live server behind it.
430 ///
431 /// ```rust,no_run
432 /// use rust_web_server::mcp::{McpContent, McpServer};
433 ///
434 /// let server = McpServer::new("my-server", "1.0")
435 /// .tool_with_context("process_batch", "Process many items", "{}", |ctx, _args| {
436 /// for i in 0..1_000_000 {
437 /// if ctx.is_cancelled() {
438 /// return Err("cancelled by client".to_string());
439 /// }
440 /// // ... process item i ...
441 /// }
442 /// Ok(McpContent::text("done"))
443 /// });
444 /// ```
445 pub fn is_cancelled(&self) -> bool {
446 self.cancellation.as_ref().is_some_and(|c| c.load(std::sync::atomic::Ordering::Relaxed))
447 }
448
449 /// **Deprecated by MCP 2026-07-28** (SEP-2577): Sampling keeps working with
450 /// clients that use it, for at least twelve months, but new servers shouldn't
451 /// rely on it.
452 ///
453 /// Ask the connected client to run LLM inference ("sampling") and block
454 /// until it replies or `timeout` elapses — the server-initiated half of
455 /// `sampling/createMessage`. This reverses the usual request direction:
456 /// the server sends a JSON-RPC *request* to the client over the `GET
457 /// /mcp` SSE stream, and the client answers with its own `POST /mcp`
458 /// carrying a JSON-RPC *response* (no `method`) that
459 /// [`McpServer::handle_request_with_context`] recognizes and routes
460 /// back here instead of treating it as an invalid request.
461 ///
462 /// Blocking (not `async fn`) is deliberate: tool handlers in this crate
463 /// are plain synchronous closures — there is no async tool handler
464 /// support yet (MCP_TODO.md's TODO-17) for this to `.await` inside of.
465 /// The calling thread parks on the response channel for up to
466 /// `timeout`; on a thread-pool server this ties up one worker for that
467 /// long, same tradeoff `crate::timeout::with_timeout` already accepts
468 /// for bounding a slow handler's *caller*.
469 ///
470 /// Fails fast, before sending anything, if: the client's `initialize`
471 /// call never declared `capabilities.sampling` (it wouldn't know what
472 /// to do with the request); this request has no session id (nothing to
473 /// address the request to); or `ctx` has no live server behind it.
474 /// Otherwise fails with a timeout error if the client never responds —
475 /// including if it simply has no `GET /mcp` SSE connection open for
476 /// this session, since there's no separate "not connected" signal.
477 ///
478 /// ```rust,no_run
479 /// use rust_web_server::mcp::{McpServer, PromptMessage, SamplingRequest};
480 /// use std::time::Duration;
481 ///
482 /// let server = McpServer::new("my-server", "1.0")
483 /// .tool_with_context("ask_llm", "Ask the connected client's model a question", "{}", |ctx, _args| {
484 /// let response = ctx.sample(
485 /// SamplingRequest {
486 /// messages: vec![PromptMessage::user("What is 2+2?")],
487 /// max_tokens: 100,
488 /// system_prompt: None,
489 /// },
490 /// Duration::from_secs(30),
491 /// )?;
492 /// Ok(response.content)
493 /// });
494 /// ```
495 pub fn sample(&self, request: SamplingRequest, timeout: Duration) -> Result<SamplingResponse, String> {
496 if !self.sampling_supported {
497 return Err("the connected client did not declare sampling support in its initialize capabilities".to_string());
498 }
499
500 let messages_json: Vec<String> = request.messages.iter().map(|m| m.to_json()).collect();
501 let system_prompt_field = match &request.system_prompt {
502 Some(s) => format!(r#","systemPrompt":"{}""#, json_escape(s)),
503 None => String::new(),
504 };
505 let params = format!(
506 r#"{{"messages":[{}],"maxTokens":{}{system_prompt_field}}}"#,
507 messages_json.join(","), request.max_tokens,
508 );
509
510 let result_json = self.send_and_wait("sampling/createMessage", Some(¶ms), timeout)?;
511 parse_sampling_response(&result_json)
512 }
513
514 /// **Deprecated by MCP 2026-07-28** (SEP-2577): Roots keeps working with
515 /// clients that use it, for at least twelve months, but new servers shouldn't
516 /// rely on it.
517 ///
518 /// Ask the connected client which filesystem roots (workspace
519 /// directories, mounted volumes, ...) it has access to — useful for a
520 /// file-system-aware tool that should only operate within the client's
521 /// declared workspace rather than the whole filesystem.
522 ///
523 /// Like [`Self::sample`], this reverses the usual request direction (a
524 /// server-initiated `roots/list` request over SSE, answered by the
525 /// client's own `POST /mcp`) and blocks the calling thread for up to
526 /// `timeout` — see `sample`'s docs for why blocking is deliberate here.
527 ///
528 /// The result is cached per session: the first call after `initialize`
529 /// (or after the client sends `notifications/roots/list_changed`, which
530 /// invalidates the cache) does a live round trip; later calls in the
531 /// same session return the cached list without sending anything. A tool
532 /// handler can call this on every invocation without worrying about
533 /// spamming the client with redundant `roots/list` requests.
534 ///
535 /// Fails fast, before sending anything, under the same conditions as
536 /// [`Self::sample`] (translated to roots): the client never declared
537 /// `capabilities.roots`, this request has no session id, or `ctx` has
538 /// no live server behind it.
539 ///
540 /// ```rust,no_run
541 /// use rust_web_server::mcp::McpServer;
542 /// use std::time::Duration;
543 ///
544 /// let server = McpServer::new("my-server", "1.0")
545 /// .tool_with_context("list_workspace_files", "List files in the client's workspace", "{}", |ctx, _args| {
546 /// let roots = ctx.list_roots(Duration::from_secs(10))?;
547 /// let names: Vec<String> = roots.iter().map(|r| r.uri.clone()).collect();
548 /// Ok(rust_web_server::mcp::McpContent::text(names.join(", ")))
549 /// });
550 /// ```
551 pub fn list_roots(&self, timeout: Duration) -> Result<Vec<McpRoot>, String> {
552 if !self.roots_supported {
553 return Err("the connected client did not declare roots support in its initialize capabilities".to_string());
554 }
555
556 if let (Some(session_id), Some(sessions)) = (&self.session_id, &self.sessions) {
557 if let Some(cached) = sessions.lock().unwrap().get(session_id).and_then(|info| info.roots.clone()) {
558 return Ok(cached);
559 }
560 }
561
562 let result_json = self.send_and_wait("roots/list", None, timeout)?;
563 let roots = parse_roots_response(&result_json)?;
564
565 if let (Some(session_id), Some(sessions)) = (&self.session_id, &self.sessions) {
566 if let Some(info) = sessions.lock().unwrap().get_mut(session_id) {
567 info.roots = Some(roots.clone());
568 }
569 }
570
571 Ok(roots)
572 }
573
574 /// Send a JSON-RPC request to the client for this context's session and
575 /// block for the reply — the shared send-and-correlate mechanic behind
576 /// [`Self::sample`] and [`Self::list_roots`]. Returns the raw `result`
577 /// JSON string (not yet parsed into either method's own response type)
578 /// on success.
579 fn send_and_wait(&self, method: &str, params_json: Option<&str>, timeout: Duration) -> Result<String, String> {
580 let session_id = self.session_id.clone()
581 .ok_or_else(|| format!("{method} requires a session (Mcp-Session-Id)"))?;
582 let sse_clients = self.sse_clients.as_ref()
583 .ok_or_else(|| format!("{method} requires a live server (this context has none)"))?;
584 let pending = self.pending_replies.as_ref()
585 .ok_or_else(|| format!("{method} requires a live server (this context has none)"))?;
586
587 let request_id = format!("\"{}\"", crate::request_id::generate_request_id());
588 let (tx, rx) = mpsc::channel::<Result<String, String>>();
589 pending.lock().unwrap().insert(request_id.clone(), tx);
590
591 let params_field = match params_json {
592 Some(p) => format!(r#","params":{p}"#),
593 None => String::new(),
594 };
595 let rpc = format!(r#"{{"jsonrpc":"2.0","id":{request_id},"method":"{method}"{params_field}}}"#);
596
597 send_sse_to_sessions(sse_clients, &[session_id], &rpc);
598
599 let outcome = rx.recv_timeout(timeout);
600 pending.lock().unwrap().remove(&request_id);
601
602 match outcome {
603 Ok(Ok(result_json)) => Ok(result_json),
604 Ok(Err(e)) => Err(e),
605 Err(_) => Err(format!("{method} timed out waiting for the client's response")),
606 }
607 }
608}
609
610/// A request to ask the connected MCP client to run LLM inference
611/// ("sampling") — build one and pass it to [`McpContext::sample`].
612///
613/// `messages` reuses [`PromptMessage`] rather than introducing a near-identical
614/// type — the spec's sampling message shape (`{"role":...,"content":{"type":"text",...}}`)
615/// is exactly what `PromptMessage` already models.
616pub struct SamplingRequest {
617 pub messages: Vec<PromptMessage>,
618 pub max_tokens: u32,
619 /// An optional system prompt to steer the client's model, per the
620 /// spec's `params.systemPrompt`.
621 pub system_prompt: Option<String>,
622}
623
624/// The client's answer to a [`SamplingRequest`], returned by
625/// [`McpContext::sample`].
626pub struct SamplingResponse {
627 /// Almost always `"assistant"`, per spec.
628 pub role: String,
629 pub content: McpContent,
630 /// The model the client actually used to generate this response, e.g.
631 /// `"claude-3-sonnet-20240307"`.
632 pub model: String,
633 /// Why the model stopped, e.g. `"endTurn"`, `"maxTokens"`, `"stopSequence"`.
634 /// `None` if the client didn't report one.
635 pub stop_reason: Option<String>,
636}
637
638/// Parse a `sampling/createMessage` response's `result` object (already
639/// extracted from the enclosing JSON-RPC envelope) into a [`SamplingResponse`].
640fn parse_sampling_response(result_json: &str) -> Result<SamplingResponse, String> {
641 let role = json_rpc::extract_str(result_json, "role").unwrap_or_else(|| "assistant".to_string());
642 let content_json = json_rpc::extract_raw(result_json, "content")
643 .ok_or_else(|| "sampling response missing content".to_string())?;
644 let kind = json_rpc::extract_str(&content_json, "type").unwrap_or_else(|| "text".to_string());
645 let content = match kind.as_str() {
646 "image" => McpContent::image(
647 json_rpc::extract_str(&content_json, "data").unwrap_or_default(),
648 json_rpc::extract_str(&content_json, "mimeType").unwrap_or_default(),
649 ),
650 _ => McpContent::text(json_rpc::extract_str(&content_json, "text").unwrap_or_default()),
651 };
652 let model = json_rpc::extract_str(result_json, "model").unwrap_or_default();
653 let stop_reason = json_rpc::extract_str(result_json, "stopReason");
654 Ok(SamplingResponse { role, content, model, stop_reason })
655}
656
657/// One filesystem root a client has access to, returned by
658/// [`McpContext::list_roots`] — a workspace directory, a mounted volume,
659/// or similar. `uri` is typically a `file://` URI per spec.
660#[derive(Debug, Clone)]
661pub struct McpRoot {
662 pub uri: String,
663 /// A human-readable label for the root, if the client provided one.
664 pub name: Option<String>,
665}
666
667/// Parse a `roots/list` response's `result` object (already extracted from
668/// the enclosing JSON-RPC envelope) into a `Vec<McpRoot>`.
669fn parse_roots_response(result_json: &str) -> Result<Vec<McpRoot>, String> {
670 let roots_array = json_rpc::extract_raw(result_json, "roots")
671 .ok_or_else(|| "roots/list response missing roots".to_string())?;
672 let roots = json_rpc::split_array_elements(&roots_array).iter().map(|elem| {
673 McpRoot {
674 uri: json_rpc::extract_str(elem, "uri").unwrap_or_default(),
675 name: json_rpc::extract_str(elem, "name"),
676 }
677 }).collect();
678 Ok(roots)
679}
680
681/// Configuration installed by [`McpServer::require_oauth`] (`sso` feature).
682/// Reuses [`crate::sso::jwks::JwksCache`] and [`crate::sso::OidcProvider`]
683/// directly rather than re-implementing JWKS fetch/verify — see
684/// `require_oauth`'s doc comment for the full rationale.
685#[cfg(feature = "sso")]
686struct OAuthConfig {
687 jwks: crate::sso::jwks::JwksCache,
688 provider: crate::sso::OidcProvider,
689 audience: String,
690}
691
692/// `clientInfo` recorded for one session at `initialize` time, looked up by
693/// `Mcp-Session-Id` for later requests in the same session. See
694/// `McpServer`'s `sessions` field doc comment for the unbounded-growth caveat.
695#[derive(Debug, Clone)]
696struct StoredClientInfo {
697 /// Last request in this session; idle past the server's session TTL, the
698 /// session is evicted (#4).
699 last_seen: std::time::Instant,
700 /// The protocol version `initialize` settled on.
701 protocol_version: &'static str,
702 name: Option<String>,
703 version: Option<String>,
704 /// Whether this session's `initialize` call declared `params.capabilities.sampling`
705 /// (spec: the client, not the server, declares sampling support — the
706 /// presence of the key is what matters, not its contents, which are
707 /// normally an empty object). Checked by [`McpContext::sample`] before
708 /// sending a `sampling/createMessage` request that the client would
709 /// have no idea how to answer.
710 supports_sampling: bool,
711 /// Whether this session's `initialize` call declared `params.capabilities.roots`,
712 /// checked by [`McpContext::list_roots`] the same way `supports_sampling` gates `sample`.
713 supports_roots: bool,
714 /// Cached result of this session's last `roots/list` round trip, read
715 /// and written by [`McpContext::list_roots`]. `None` means "never
716 /// fetched, or invalidated" — set back to `None` when this session
717 /// sends `notifications/roots/list_changed`, so the next `list_roots`
718 /// call does a fresh round trip instead of serving stale data.
719 roots: Option<Vec<McpRoot>>,
720}
721
722// ── ToolAnnotations ───────────────────────────────────────────────────────────
723
724/// Behavioral hints for a tool, per the MCP 2025-03-26 spec's tool
725/// annotations. Clients (Claude Desktop and others) use these to decide
726/// whether to warn or ask for confirmation before calling a tool — e.g. skip
727/// confirmation for a read-only tool, or warn before a destructive one.
728///
729/// Every field is a *hint*, not something this server enforces or verifies —
730/// nothing stops a handler registered with `read_only_hint: Some(true)` from
731/// actually writing to disk. A well-behaved server sets these accurately;
732/// a client is free to ignore them or ask for confirmation anyway.
733///
734/// Register with [`McpServer::tool_annotated`]. Build one with plain struct
735/// syntax — every field defaults to `None` (no hint given, the client's own
736/// default applies):
737///
738/// ```rust
739/// use rust_web_server::mcp::ToolAnnotations;
740///
741/// let destructive = ToolAnnotations {
742/// destructive_hint: Some(true),
743/// read_only_hint: Some(false),
744/// ..Default::default()
745/// };
746/// ```
747#[derive(Debug, Clone, Copy, Default)]
748pub struct ToolAnnotations {
749 /// The tool does not modify its environment.
750 pub read_only_hint: Option<bool>,
751 /// The tool may perform destructive updates (only meaningful when
752 /// `read_only_hint` is not `Some(true)`).
753 pub destructive_hint: Option<bool>,
754 /// Calling the tool repeatedly with the same arguments has no additional
755 /// effect beyond the first call.
756 pub idempotent_hint: Option<bool>,
757 /// The tool may interact with an open-ended set of external entities
758 /// (e.g. web search), as opposed to a fixed, closed set.
759 pub open_world_hint: Option<bool>,
760}
761
762impl ToolAnnotations {
763 /// Render as a JSON object containing only the hints that are `Some`,
764 /// using the spec's camelCase key names. Returns `"{}"` if every field
765 /// is `None`.
766 fn to_json(self) -> String {
767 let mut fields = Vec::with_capacity(4);
768 if let Some(v) = self.read_only_hint {
769 fields.push(format!(r#""readOnlyHint":{v}"#));
770 }
771 if let Some(v) = self.destructive_hint {
772 fields.push(format!(r#""destructiveHint":{v}"#));
773 }
774 if let Some(v) = self.idempotent_hint {
775 fields.push(format!(r#""idempotentHint":{v}"#));
776 }
777 if let Some(v) = self.open_world_hint {
778 fields.push(format!(r#""openWorldHint":{v}"#));
779 }
780 format!("{{{}}}", fields.join(","))
781 }
782}
783
784// ── LogLevel ──────────────────────────────────────────────────────────────────
785
786/// RFC 5424 syslog severity levels, as used by the MCP `logging/setLevel`
787/// request and `notifications/message` log entries — ordered from most to
788/// least verbose so `level < min_level` comparisons work directly (this
789/// relies on declaration order matching severity order; don't reorder the
790/// variants).
791///
792/// ```rust
793/// use rust_web_server::mcp::LogLevel;
794///
795/// assert!(LogLevel::Debug < LogLevel::Warning);
796/// assert!(LogLevel::Emergency > LogLevel::Error);
797/// assert_eq!(LogLevel::parse("warning"), Some(LogLevel::Warning));
798/// assert_eq!(LogLevel::Warning.as_str(), "warning");
799/// ```
800#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
801pub enum LogLevel {
802 Debug,
803 Info,
804 Notice,
805 Warning,
806 Error,
807 Critical,
808 Alert,
809 Emergency,
810}
811
812impl LogLevel {
813 /// Parse the MCP spec's lowercase level name. Returns `None` for
814 /// anything that isn't one of the eight recognized levels.
815 pub fn parse(s: &str) -> Option<Self> {
816 match s {
817 "debug" => Some(LogLevel::Debug),
818 "info" => Some(LogLevel::Info),
819 "notice" => Some(LogLevel::Notice),
820 "warning" => Some(LogLevel::Warning),
821 "error" => Some(LogLevel::Error),
822 "critical" => Some(LogLevel::Critical),
823 "alert" => Some(LogLevel::Alert),
824 "emergency" => Some(LogLevel::Emergency),
825 _ => None,
826 }
827 }
828
829 /// The MCP spec's lowercase level name, e.g. `"warning"`.
830 pub fn as_str(self) -> &'static str {
831 match self {
832 LogLevel::Debug => "debug",
833 LogLevel::Info => "info",
834 LogLevel::Notice => "notice",
835 LogLevel::Warning => "warning",
836 LogLevel::Error => "error",
837 LogLevel::Critical => "critical",
838 LogLevel::Alert => "alert",
839 LogLevel::Emergency => "emergency",
840 }
841 }
842}
843
844// ── internal handler registrations ───────────────────────────────────────────
845
846type ToolFn = Arc<dyn Fn(McpContext, &str) -> Result<McpContent, String> + Send + Sync>;
847type ResourceFn = Arc<dyn Fn(&str) -> Result<McpContent, String> + Send + Sync>;
848type PromptFn = Arc<dyn Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync>;
849/// `Fn(argument_name, partial_value) -> candidate completion strings`.
850type CompletionFn = Arc<dyn Fn(&str, &str) -> Result<Vec<String>, String> + Send + Sync>;
851type ToolFilter = Arc<dyn Fn(&McpContext, &str) -> bool + Send + Sync>;
852/// An async tool handler, boxed so `AsyncToolDef` can store handlers with
853/// different concrete `Future` types uniformly. The `Future` itself doesn't
854/// need `Send` for [`crate::async_bridge::block_on_isolated`] (which drives
855/// it to completion without ever moving it across a thread boundary), but
856/// it's required here anyway for consistency with [`crate::async_state`]'s
857/// equivalent handler type — the common case in practice, and one less
858/// surprise for anyone switching between the two async handler styles.
859#[cfg(feature = "http2")]
860type AsyncToolFn = Arc<dyn Fn(String) -> Pin<Box<dyn Future<Output = Result<McpContent, String>> + Send>> + Send + Sync>;
861
862#[derive(Clone)]
863struct ToolDef {
864 name: String,
865 description: String,
866 input_schema: String,
867 annotations: Option<ToolAnnotations>,
868 meta: ToolMeta,
869 handler: ToolFn,
870}
871
872/// Optional `tools/list` fields set after registration, by tool name:
873/// [`McpServer::tool_title`], [`McpServer::tool_icon`],
874/// [`McpServer::tool_output_schema`].
875#[derive(Clone, Default)]
876struct ToolMeta {
877 title: Option<String>,
878 icons: Vec<McpIcon>,
879 output_schema: Option<String>,
880}
881
882/// An icon for the server, a tool, a resource or a prompt (MCP 2025-11-25):
883/// `src` is an `https:` or `data:` URL.
884#[derive(Clone, Debug, PartialEq, Eq)]
885pub struct McpIcon {
886 pub src: String,
887 pub mime_type: Option<String>,
888 /// e.g. `["48x48"]`, `["any"]` for SVG.
889 pub sizes: Vec<String>,
890}
891
892impl McpIcon {
893 /// An icon at `src`, with no MIME type or sizes.
894 pub fn new(src: impl Into<String>) -> Self {
895 McpIcon { src: src.into(), mime_type: None, sizes: Vec::new() }
896 }
897
898 /// Set the MIME type (`image/png`, `image/svg+xml`, …).
899 pub fn mime_type(mut self, mime_type: impl Into<String>) -> Self {
900 self.mime_type = Some(mime_type.into());
901 self
902 }
903
904 /// Add a size (`48x48`, or `any` for scalable formats).
905 pub fn size(mut self, size: impl Into<String>) -> Self {
906 self.sizes.push(size.into());
907 self
908 }
909
910 fn to_json(&self) -> String {
911 let mut out = format!(r#"{{"src":"{}""#, json_escape(&self.src));
912 if let Some(mime) = &self.mime_type {
913 out.push_str(&format!(r#","mimeType":"{}""#, json_escape(mime)));
914 }
915 if !self.sizes.is_empty() {
916 let sizes: Vec<String> = self.sizes.iter().map(|s| format!(r#""{}""#, json_escape(s))).collect();
917 out.push_str(&format!(r#","sizes":[{}]"#, sizes.join(",")));
918 }
919 out.push('}');
920 out
921 }
922}
923
924fn icons_json(icons: &[McpIcon]) -> String {
925 if icons.is_empty() {
926 return String::new();
927 }
928 let icons: Vec<String> = icons.iter().map(McpIcon::to_json).collect();
929 format!(r#","icons":[{}]"#, icons.join(","))
930}
931
932/// A tool registered via [`McpServer::async_tool`]/[`McpServer::register_async_tool`]
933/// — stored separately from [`ToolDef`] rather than unifying the two behind
934/// a handler enum, since sync and async tools are otherwise identical and
935/// keeping them apart touches far less of the existing (already
936/// substantial) tool-handling code. `tools/list` and `tools/call` merge
937/// both collections transparently; from the client's perspective there is
938/// no distinction.
939#[cfg(feature = "http2")]
940#[derive(Clone)]
941struct AsyncToolDef {
942 name: String,
943 description: String,
944 input_schema: String,
945 annotations: Option<ToolAnnotations>,
946 meta: ToolMeta,
947 handler: AsyncToolFn,
948}
949
950#[derive(Clone)]
951struct ResourceDef {
952 uri_template: String,
953 name: String,
954 description: String,
955 handler: ResourceFn,
956}
957
958#[derive(Clone)]
959struct PromptDef {
960 name: String,
961 description: String,
962 arguments: Vec<PromptArgDef>,
963 handler: PromptFn,
964}
965
966/// One `.completion()` registration — completion candidates for a single
967/// named argument of a single tool or prompt. `ref_type` is the short form
968/// passed to `.completion()` (e.g. `"tool"`, `"prompt"`), matched against the
969/// request's `ref.type` (e.g. `"ref/tool"`) with the `"ref/"` prefix
970/// stripped, not the raw wire value.
971#[derive(Clone)]
972struct CompletionDef {
973 ref_type: String,
974 ref_name: String,
975 handler: CompletionFn,
976}
977
978// ── McpServer ─────────────────────────────────────────────────────────────────
979
980/// An HTTP server that implements the MCP protocol (2024-11-05 to 2025-11-25).
981///
982/// Register tools, resources, and prompts with the builder methods, then pass
983/// the server to [`Server::run`] (or [`Server::run_tls`]) as an [`Application`].
984/// Requests that do not match the MCP endpoint fall through to the built-in
985/// [`App`] controller chain.
986#[derive(Clone)]
987pub struct McpServer {
988 server_name: String,
989 server_version: String,
990 path: String,
991 /// `Arc<RwLock<_>>` (not a plain `Vec`) so a running server's tool list
992 /// can be mutated at runtime — see [`Self::register_tool`]/[`Self::remove_tool`]
993 /// — and every clone of this `McpServer` (each connection thread gets
994 /// one) sees the same live list.
995 tools: Arc<RwLock<Vec<ToolDef>>>,
996 /// Tools registered via [`Self::async_tool`]/[`Self::register_async_tool`],
997 /// bridged into synchronous [`Application::execute`] via
998 /// [`crate::async_bridge::block_on_isolated`] at call time. Kept
999 /// separate from `tools` rather than merged into one collection — see
1000 /// [`AsyncToolDef`]'s doc comment.
1001 #[cfg(feature = "http2")]
1002 async_tools: Arc<RwLock<Vec<AsyncToolDef>>>,
1003 resources: Arc<RwLock<Vec<ResourceDef>>>,
1004 prompts: Arc<RwLock<Vec<PromptDef>>>,
1005 /// Argument completion providers registered via [`Self::completion`].
1006 /// `initialize` advertises the `completions` capability iff this is
1007 /// non-empty at that moment.
1008 completions: Arc<RwLock<Vec<CompletionDef>>>,
1009 fallback: Option<Arc<dyn Application + Send + Sync>>,
1010 auth_token: Option<String>,
1011 /// `(token, identity)` pairs from [`Self::require_bearer_tokens`].
1012 bearer_tokens: Vec<(String, String)>,
1013 /// Which tools a caller may see and call ([`Self::tool_filter`]).
1014 tool_filter: Option<ToolFilter>,
1015 /// Origins a browser may call the endpoint from (see
1016 /// [`Self::allowed_origins`]); a request with any other `Origin` is
1017 /// refused.
1018 allowed_origins: Vec<String>,
1019 /// Installed by [`Self::require_oauth`] (`sso` feature) — an alternative
1020 /// to `auth_token` that verifies a client-supplied OAuth 2.0 / OIDC
1021 /// bearer JWT via JWKS instead of comparing against one static shared
1022 /// secret. If both are somehow configured, OAuth verification takes
1023 /// precedence and `auth_token` is ignored (see [`Self::require_oauth`]).
1024 #[cfg(feature = "sso")]
1025 oauth: Option<Arc<OAuthConfig>>,
1026 /// Max items per page for `tools/list`/`resources/list`/`prompts/list`,
1027 /// set via [`Self::page_size`]. `None` (the default) means no pagination
1028 /// — every item comes back in one response, same as before pagination
1029 /// existed.
1030 page_size: Option<usize>,
1031 /// `clientInfo` recorded per `Mcp-Session-Id`, minted at `initialize` time.
1032 /// `Arc<Mutex<_>>` so every clone of this `McpServer` shares one map.
1033 /// Bounded (#4): a session idle for `session_ttl` is evicted when next
1034 /// seen or when a new one is created, `max_sessions` caps the map
1035 /// (oldest evicted first), and `DELETE /mcp` ends one. An evicted
1036 /// session's subscriptions and SSE streams go with it.
1037 sessions: Arc<Mutex<HashMap<String, StoredClientInfo>>>,
1038 /// Idle time after which a session is evicted (default 1 hour).
1039 session_ttl: Duration,
1040 /// Most sessions kept (default 10 000).
1041 max_sessions: usize,
1042 /// Most open `GET /mcp` SSE streams (default 1000); past it `503`.
1043 max_sse_clients: usize,
1044 /// `serverInfo.title` and `serverInfo.icons`.
1045 server_title: Option<String>,
1046 server_icons: Vec<McpIcon>,
1047 /// Senders for every currently-connected `GET /mcp` SSE client, pushed to
1048 /// by [`Self::notify`]. `Arc<Mutex<_>>` so every clone of this `McpServer`
1049 /// broadcasts to the same set of listeners.
1050 ///
1051 /// Entries for clients that disconnected (or whose buffer filled up) are
1052 /// only pruned lazily, the next time [`Self::notify`] is called and its
1053 /// `try_send` fails — not proactively, since nothing else observes the
1054 /// underlying `Receiver` closing. A server that never calls `notify`
1055 /// after clients disconnect will accumulate dead entries here.
1056 sse_clients: Arc<Mutex<Vec<SseClient>>>,
1057 /// Whether `initialize`'s advertised `capabilities` includes `"logging":{}`.
1058 /// Set via [`Self::logging_enabled`]. This only controls what's
1059 /// advertised — [`Self::log`] works regardless, same as [`Self::notify`]
1060 /// does; a spec-honest client just wouldn't call `logging/setLevel` in
1061 /// the first place if the capability was never advertised.
1062 logging_enabled: bool,
1063 /// The minimum [`LogLevel`] that [`Self::log`] will actually push,
1064 /// settable at runtime by a client's `logging/setLevel` request. Starts
1065 /// at [`LogLevel::Debug`] (the least restrictive level, i.e. nothing is
1066 /// filtered) until a client requests otherwise.
1067 min_log_level: Arc<Mutex<LogLevel>>,
1068 /// In-flight `tools/call` cancellation flags, keyed by the request's raw
1069 /// `id` JSON token. Populated just before dispatching a `tools/call` and
1070 /// removed again once it returns (success, error, or the flag was never
1071 /// checked) — unlike `sessions`/`sse_clients`, this map's entries are
1072 /// always short-lived by construction, not merely "not evicted."
1073 /// `notifications/cancelled` looks up `params.requestId` here and flips
1074 /// the flag; see [`McpContext::is_cancelled`] for how (and whether) a
1075 /// handler ever observes it.
1076 cancellations: Arc<Mutex<HashMap<String, Arc<std::sync::atomic::AtomicBool>>>>,
1077 /// Resource URI -> session ids subscribed to it via
1078 /// `resources/subscribe`, consulted by [`Self::notify_resource_updated`].
1079 /// A session id is only meaningful if the client also opened `GET /mcp`
1080 /// with the same `Mcp-Session-Id` header (see [`SseClient`]) — a session
1081 /// that only ever calls `resources/subscribe` over POST with no matching
1082 /// SSE connection is subscribed in name only, since there's no channel
1083 /// to push the update over. Entries are removed by explicit
1084 /// `resources/unsubscribe` calls (URIs with zero subscribers are pruned
1085 /// entirely); a session that disconnects without unsubscribing leaves a
1086 /// harmless stale session id behind, same "no proactive eviction"
1087 /// tradeoff already documented for `sessions`.
1088 subscriptions: Arc<Mutex<HashMap<String, Vec<String>>>>,
1089 /// In-flight `sampling/createMessage` requests this server has sent to
1090 /// a client, keyed by the raw (already-quoted) request id token this
1091 /// server minted. A JSON-RPC *response* has no `method` field, so
1092 /// [`Self::handle_request_with_context`]/[`Self::handle_batch`]
1093 /// special-case a method-less body with a recognized `id` as a
1094 /// sampling reply (via [`Self::try_deliver_sampling_response`]) rather
1095 /// than an invalid request. See [`McpContext::sample`] for the
1096 /// send-and-block side.
1097 pending_replies: Arc<Mutex<HashMap<String, mpsc::Sender<Result<String, String>>>>>,
1098}
1099
1100/// One `GET /mcp` SSE client: its outbound channel plus the
1101/// `Mcp-Session-Id` it connected with, if any. Broadcast notifications
1102/// (`.notify()`, `.log()`, `list_changed`) go to every client regardless of
1103/// `session_id`; [`McpServer::notify_resource_updated`] targets only
1104/// clients whose `session_id` is subscribed to the changed URI.
1105#[derive(Debug)]
1106struct SseClient {
1107 session_id: Option<String>,
1108 sender: SseSender,
1109}
1110
1111/// One `GET /mcp` SSE client's outbound channel. Bounded so a slow or stuck
1112/// client can't grow memory without limit; [`McpServer::notify`] uses
1113/// `try_send` (never blocks) and drops any client whose buffer is full.
1114type SseSender = SyncSender<Vec<u8>>;
1115
1116/// Max buffered-but-unread SSE frames per client before it's treated as dead.
1117const SSE_CHANNEL_CAPACITY: usize = 32;
1118
1119/// Default idle time after which an MCP session is evicted.
1120pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(3600);
1121/// Default cap on MCP sessions.
1122pub const DEFAULT_MAX_SESSIONS: usize = 10_000;
1123/// Default cap on open `GET /mcp` SSE streams.
1124pub const DEFAULT_MAX_SSE_CLIENTS: usize = 1000;
1125
1126/// How often an idle SSE connection gets a `: keep-alive` comment.
1127const SSE_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(15);
1128
1129/// Max `completion/complete` values returned in one response, per spec
1130/// guidance that servers SHOULD NOT return more than 100. A handler
1131/// returning more has the rest reported via `hasMore`/`total` rather than
1132/// silently included.
1133const MAX_COMPLETION_VALUES: usize = 100;
1134
1135impl McpServer {
1136 /// Create a new `McpServer`. The default MCP endpoint is `POST /mcp`.
1137 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
1138 McpServer {
1139 server_name: name.into(),
1140 server_version: version.into(),
1141 path: "/mcp".to_string(),
1142 tools: Arc::new(RwLock::new(Vec::new())),
1143 #[cfg(feature = "http2")]
1144 async_tools: Arc::new(RwLock::new(Vec::new())),
1145 resources: Arc::new(RwLock::new(Vec::new())),
1146 prompts: Arc::new(RwLock::new(Vec::new())),
1147 completions: Arc::new(RwLock::new(Vec::new())),
1148 fallback: None,
1149 auth_token: None,
1150 bearer_tokens: Vec::new(),
1151 tool_filter: None,
1152 allowed_origins: Vec::new(),
1153 #[cfg(feature = "sso")]
1154 oauth: None,
1155 page_size: None,
1156 sessions: Arc::new(Mutex::new(HashMap::new())),
1157 session_ttl: DEFAULT_SESSION_TTL,
1158 max_sessions: DEFAULT_MAX_SESSIONS,
1159 max_sse_clients: DEFAULT_MAX_SSE_CLIENTS,
1160 server_title: None,
1161 server_icons: Vec::new(),
1162 sse_clients: Arc::new(Mutex::new(Vec::new())),
1163 logging_enabled: false,
1164 min_log_level: Arc::new(Mutex::new(LogLevel::Debug)),
1165 cancellations: Arc::new(Mutex::new(HashMap::new())),
1166 subscriptions: Arc::new(Mutex::new(HashMap::new())),
1167 pending_replies: Arc::new(Mutex::new(HashMap::new())),
1168 }
1169 }
1170
1171 /// Evict a session after it has been idle this long (default 1 hour,
1172 /// [`DEFAULT_SESSION_TTL`]). A request with an evicted session's
1173 /// `Mcp-Session-Id` gets `404`, which tells the client to initialize a
1174 /// new session (MCP Streamable HTTP transport).
1175 pub fn session_ttl(mut self, ttl: Duration) -> Self {
1176 self.session_ttl = ttl;
1177 self
1178 }
1179
1180 /// Keep at most `n` sessions (default [`DEFAULT_MAX_SESSIONS`]); a new
1181 /// one past that evicts the least recently used.
1182 pub fn max_sessions(mut self, n: usize) -> Self {
1183 self.max_sessions = n.max(1);
1184 self
1185 }
1186
1187 /// Accept at most `n` open `GET /mcp` SSE streams (default
1188 /// [`DEFAULT_MAX_SSE_CLIENTS`]); another gets `503 Service Unavailable`.
1189 pub fn max_sse_clients(mut self, n: usize) -> Self {
1190 self.max_sse_clients = n;
1191 self
1192 }
1193
1194 /// A human-readable title for tool `name` (`title` in `tools/list`,
1195 /// MCP 2025-06-18); `name` stays the identifier. No effect for an
1196 /// unknown name — register the tool first.
1197 pub fn tool_title(self, name: &str, title: impl Into<String>) -> Self {
1198 let title = title.into();
1199 self.with_tool_meta(name, |meta| meta.title = Some(title.clone()));
1200 self
1201 }
1202
1203 /// Add an icon to tool `name` (`icons` in `tools/list`, MCP 2025-11-25).
1204 pub fn tool_icon(self, name: &str, icon: McpIcon) -> Self {
1205 self.with_tool_meta(name, |meta| meta.icons.push(icon.clone()));
1206 self
1207 }
1208
1209 /// Declare tool `name`'s result shape: a JSON Schema object (an `object`
1210 /// type at the top). Clients may validate the `structuredContent` the
1211 /// handler returns with [`McpContent::structured`] against it.
1212 pub fn tool_output_schema(self, name: &str, schema: &str) -> Self {
1213 let schema = schema.to_string();
1214 self.with_tool_meta(name, |meta| meta.output_schema = Some(schema.clone()));
1215 self
1216 }
1217
1218 /// Set (or replace) the behaviour hints of tool `name` — e.g. read-only,
1219 /// or destructive — for a tool registered without them.
1220 pub fn tool_annotations(self, name: &str, annotations: ToolAnnotations) -> Self {
1221 for tool in self.tools.write().unwrap().iter_mut().filter(|t| t.name == name) {
1222 tool.annotations = Some(annotations.clone());
1223 }
1224 #[cfg(feature = "http2")]
1225 for tool in self.async_tools.write().unwrap().iter_mut().filter(|t| t.name == name) {
1226 tool.annotations = Some(annotations.clone());
1227 }
1228 self
1229 }
1230
1231 fn with_tool_meta(&self, name: &str, mut change: impl FnMut(&mut ToolMeta)) {
1232 for tool in self.tools.write().unwrap().iter_mut().filter(|t| t.name == name) {
1233 change(&mut tool.meta);
1234 }
1235 #[cfg(feature = "http2")]
1236 for tool in self.async_tools.write().unwrap().iter_mut().filter(|t| t.name == name) {
1237 change(&mut tool.meta);
1238 }
1239 }
1240
1241 /// A human-readable server title (`serverInfo.title`, MCP 2025-06-18).
1242 pub fn title(mut self, title: impl Into<String>) -> Self {
1243 self.server_title = Some(title.into());
1244 self
1245 }
1246
1247 /// Add a server icon (`serverInfo.icons`, MCP 2025-11-25).
1248 pub fn icon(mut self, icon: McpIcon) -> Self {
1249 self.server_icons.push(icon);
1250 self
1251 }
1252
1253 /// Mark a session as used now. `false` when it doesn't exist or has been
1254 /// idle past the TTL (then it's evicted).
1255 fn touch_session(&self, session_id: &str) -> bool {
1256 let expired = {
1257 let mut sessions = self.sessions.lock().unwrap();
1258 match sessions.get_mut(session_id) {
1259 None => return false,
1260 Some(info) if info.last_seen.elapsed() > self.session_ttl => {
1261 sessions.remove(session_id);
1262 true
1263 }
1264 Some(info) => {
1265 info.last_seen = std::time::Instant::now();
1266 false
1267 }
1268 }
1269 };
1270 if expired {
1271 self.forget_sessions(&[session_id.to_string()]);
1272 return false;
1273 }
1274 true
1275 }
1276
1277 /// Before a new session: drop expired ones, then the least recently used
1278 /// until there's room under `max_sessions`.
1279 fn make_room_for_a_session(&self) {
1280 let evicted: Vec<String> = {
1281 let mut sessions = self.sessions.lock().unwrap();
1282 let ttl = self.session_ttl;
1283 let mut evicted: Vec<String> = sessions.iter()
1284 .filter(|(_, info)| info.last_seen.elapsed() > ttl)
1285 .map(|(id, _)| id.clone())
1286 .collect();
1287 for id in &evicted {
1288 sessions.remove(id);
1289 }
1290 if sessions.len() >= self.max_sessions {
1291 let mut by_age: Vec<(std::time::Instant, String)> =
1292 sessions.iter().map(|(id, info)| (info.last_seen, id.clone())).collect();
1293 by_age.sort();
1294 for (_, id) in by_age.into_iter().take(sessions.len() + 1 - self.max_sessions) {
1295 sessions.remove(&id);
1296 evicted.push(id);
1297 }
1298 }
1299 evicted
1300 };
1301 if !evicted.is_empty() {
1302 self.forget_sessions(&evicted);
1303 }
1304 }
1305
1306 /// The rest of an evicted or ended session: its resource subscriptions
1307 /// and its SSE streams (dropping the sender ends the stream).
1308 fn forget_sessions(&self, ids: &[String]) {
1309 let mut subscriptions = self.subscriptions.lock().unwrap();
1310 for subscribers in subscriptions.values_mut() {
1311 subscribers.retain(|sid| !ids.contains(sid));
1312 }
1313 subscriptions.retain(|_, subscribers| !subscribers.is_empty());
1314 drop(subscriptions);
1315 self.sse_clients.lock().unwrap()
1316 .retain(|client| client.session_id.as_ref().is_none_or(|sid| !ids.contains(sid)));
1317 }
1318
1319 /// Number of live sessions (for metrics and tests).
1320 pub fn session_count(&self) -> usize {
1321 self.sessions.lock().unwrap().len()
1322 }
1323
1324 /// Cap `tools/list`, `resources/list`, and `prompts/list` to at most `n`
1325 /// items per response, enabling cursor-based pagination: a response with
1326 /// more items remaining includes `"nextCursor"`, an opaque string the
1327 /// client echoes back as `params.cursor` on its next call to get the next
1328 /// page. `n` is clamped to a minimum of `1`.
1329 ///
1330 /// Without calling this, every registered tool/resource/prompt is
1331 /// returned in a single response — the default, and the only behavior
1332 /// before pagination existed.
1333 ///
1334 /// ```rust
1335 /// use rust_web_server::mcp::McpServer;
1336 ///
1337 /// let server = McpServer::new("my-server", "1.0").page_size(50);
1338 /// ```
1339 pub fn page_size(mut self, n: usize) -> Self {
1340 self.page_size = Some(n.max(1));
1341 self
1342 }
1343
1344 /// Push a JSON-RPC notification (no `id` — fire-and-forget, per the
1345 /// spec) to every client currently connected to the `GET /mcp` SSE
1346 /// stream, framed as an SSE `data:` event.
1347 ///
1348 /// `params_json`, if given, must already be a valid JSON value (usually
1349 /// an object) — it's spliced in verbatim, not escaped or re-serialized.
1350 ///
1351 /// Never blocks: a client whose event buffer is full (not reading fast
1352 /// enough) is treated the same as a disconnected one and dropped from
1353 /// the broadcast list, same as `notify` would drop it anyway.
1354 ///
1355 /// ```rust
1356 /// use rust_web_server::mcp::McpServer;
1357 ///
1358 /// let server = McpServer::new("my-server", "1.0");
1359 /// server.notify("notifications/message", Some(r#"{"level":"info","data":"hello"}"#));
1360 /// ```
1361 pub fn notify(&self, method: &str, params_json: Option<&str>) {
1362 let json = render_notification(method, params_json);
1363 broadcast_sse_to(&self.sse_clients, &json);
1364 }
1365
1366 /// Push `notifications/resources/updated` for `uri` to every session
1367 /// that called `resources/subscribe` for it — the mechanism behind
1368 /// live-updating resource panels (e.g. Claude Desktop watching a
1369 /// config file or a dashboard resource for changes). Call this from
1370 /// wherever your application actually changes the underlying data a
1371 /// resource represents (a file watcher, a webhook handler, a database
1372 /// trigger poll, ...).
1373 ///
1374 /// A no-op if nobody has subscribed to `uri` — including if every
1375 /// subscriber's `GET /mcp` SSE connection has since disconnected
1376 /// (pruned the same way [`Self::notify`] prunes dead broadcast clients,
1377 /// but the `subscriptions` bookkeeping for `uri` itself is untouched
1378 /// either way; only [`Self::do_resource_unsubscribe`] removes that).
1379 ///
1380 /// ```rust
1381 /// use rust_web_server::mcp::McpServer;
1382 ///
1383 /// let server = McpServer::new("my-server", "1.0");
1384 /// // Elsewhere, e.g. after reloading a watched config file:
1385 /// server.notify_resource_updated("config://main");
1386 /// ```
1387 pub fn notify_resource_updated(&self, uri: &str) {
1388 let session_ids = match self.subscriptions.lock().unwrap().get(uri) {
1389 Some(ids) if !ids.is_empty() => ids.clone(),
1390 _ => return,
1391 };
1392 let params = format!(r#"{{"uri":"{}"}}"#, json_escape(uri));
1393 let json = render_notification("notifications/resources/updated", Some(¶ms));
1394 send_sse_to_sessions(&self.sse_clients, &session_ids, &json);
1395 }
1396
1397 /// Handle `GET /mcp`: register a new SSE client and return a
1398 /// `text/event-stream` response that streams from its channel until the
1399 /// connection closes. See the module docs' SSE section for the wire
1400 /// details (keep-alive interval, backpressure behavior).
1401 ///
1402 /// Tags the client with this request's `Mcp-Session-Id` header, if
1403 /// present, so [`Self::notify_resource_updated`] can route
1404 /// `notifications/resources/updated` to just the sessions that
1405 /// subscribed via `resources/subscribe` — a client that connects
1406 /// without the header can still receive broadcasts (`.notify()`,
1407 /// `.log()`, `list_changed`) but can never be a subscription target.
1408 fn start_sse_stream(&self, request: &Request) -> Response {
1409 let session_id = request.get_header("Mcp-Session-Id".to_string()).map(|h| h.value.clone());
1410 let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(SSE_CHANNEL_CAPACITY);
1411 {
1412 let mut clients = self.sse_clients.lock().unwrap();
1413 if clients.len() >= self.max_sse_clients {
1414 // Disconnected clients are otherwise only noticed on the next
1415 // broadcast: a comment frame finds them now (#4).
1416 clients.retain(|client| !matches!(client.sender.try_send(b": ping\n\n".to_vec()), Err(mpsc::TrySendError::Disconnected(_))));
1417 }
1418 if clients.len() >= self.max_sse_clients {
1419 let mut busy = Response::new();
1420 busy.status_code = *STATUS_CODE_REASON_PHRASE.n503_service_unavailable.status_code;
1421 busy.reason_phrase = STATUS_CODE_REASON_PHRASE.n503_service_unavailable.reason_phrase.to_string();
1422 busy.headers.push(Header { name: "Retry-After".into(), value: "30".into() });
1423 return busy;
1424 }
1425 clients.push(SseClient { session_id: session_id.map(|v| v.into_owned()), sender: tx });
1426 }
1427
1428 let mut response = Response::new();
1429 response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
1430 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
1431 response.headers.push(Header {
1432 name: Header::_CONTENT_TYPE.into(),
1433 value: "text/event-stream".into(),
1434 });
1435 response.headers.push(Header {
1436 name: Header::_CACHE_CONTROL.into(),
1437 value: "no-cache".into(),
1438 });
1439 response.headers.push(Header {
1440 name: "X-Accel-Buffering".into(),
1441 value: "no".into(),
1442 });
1443 response.stream_pipe = Some(Box::new(SseChannelReader::new(rx)));
1444 response
1445 }
1446
1447 /// **Deprecated by MCP 2026-07-28** (SEP-2577): Logging keeps working with
1448 /// clients that use it, for at least twelve months, but new servers shouldn't
1449 /// rely on it.
1450 ///
1451 /// Advertise the `logging` capability (`"logging":{}`) in `initialize`'s
1452 /// response, so clients know they can call `logging/setLevel` and expect
1453 /// `notifications/message` log entries over the `GET /mcp` SSE stream.
1454 ///
1455 /// This only changes what's *advertised* — [`Self::log`] pushes log
1456 /// entries regardless of whether this was called, same as [`Self::notify`]
1457 /// works unconditionally. A spec-honest client simply wouldn't send
1458 /// `logging/setLevel` in the first place without seeing the capability.
1459 ///
1460 /// ```rust
1461 /// use rust_web_server::mcp::McpServer;
1462 ///
1463 /// let server = McpServer::new("my-server", "1.0").logging_enabled();
1464 /// ```
1465 pub fn logging_enabled(mut self) -> Self {
1466 self.logging_enabled = true;
1467 self
1468 }
1469
1470 /// **Deprecated by MCP 2026-07-28** (SEP-2577): Logging keeps working with
1471 /// clients that use it, for at least twelve months, but new servers shouldn't
1472 /// rely on it.
1473 ///
1474 /// Push a `notifications/message` log entry to every client connected to
1475 /// the `GET /mcp` SSE stream, if `level` is at or above the level most
1476 /// recently set via a client's `logging/setLevel` request (or
1477 /// [`LogLevel::Debug`] — i.e. every level — if none has been set yet).
1478 ///
1479 /// `data_json` must already be a valid JSON value (the spec allows any
1480 /// type here, not just an object — a plain string is fine) — it's
1481 /// spliced in verbatim, not escaped or re-serialized. `logger`, if
1482 /// given, identifies the log's source (e.g. a module or subsystem name)
1483 /// and is escaped automatically.
1484 ///
1485 /// ```rust
1486 /// use rust_web_server::mcp::{LogLevel, McpServer};
1487 ///
1488 /// let server = McpServer::new("my-server", "1.0").logging_enabled();
1489 /// server.log(LogLevel::Warning, Some("database"), r#""connection pool exhausted""#);
1490 /// ```
1491 pub fn log(&self, level: LogLevel, logger: Option<&str>, data_json: &str) {
1492 if level < *self.min_log_level.lock().unwrap() {
1493 return;
1494 }
1495 let logger_field = match logger {
1496 Some(l) => format!(r#","logger":"{}""#, json_escape(l)),
1497 None => String::new(),
1498 };
1499 let params = format!(r#"{{"level":"{}"{logger_field},"data":{data_json}}}"#, level.as_str());
1500 self.notify("notifications/message", Some(¶ms));
1501 }
1502
1503 /// Handle `logging/setLevel`: store the requested minimum level so
1504 /// subsequent [`Self::log`] calls filter against it. Returns
1505 /// `INVALID_PARAMS` for a missing or unrecognized `params.level`.
1506 fn do_set_log_level(&self, body: &str) -> Result<String, (i32, String)> {
1507 let params = json_rpc::extract_raw(body, "params")
1508 .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
1509 let level_str = json_rpc::extract_str(¶ms, "level")
1510 .ok_or((json_rpc::INVALID_PARAMS, "Missing level".to_string()))?;
1511 let level = LogLevel::parse(&level_str)
1512 .ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Unknown log level: {level_str}")))?;
1513 *self.min_log_level.lock().unwrap() = level;
1514 Ok("{}".to_string())
1515 }
1516
1517 // ── dynamic registration ──────────────────────────────────────────────────
1518 //
1519 // Unlike `.tool()`/`.resource()`/`.prompt()` (consuming builders, called
1520 // before the server starts serving requests), these take `&self` and can
1521 // be called at any time from any thread holding a clone of this
1522 // `McpServer` — e.g. after discovering a plugin, connecting to a
1523 // database, or reacting to a hot-reloaded config file. Every clone
1524 // shares the same underlying `Arc<RwLock<Vec<_>>>`, so a mutation made
1525 // through one clone is immediately visible to every other clone,
1526 // including the ones handling concurrent requests on other threads.
1527 //
1528 // Each registration/removal pushes the corresponding
1529 // `notifications/{tools,resources,prompts}/list_changed` event (no
1530 // params, per spec) to every `GET /mcp` SSE client via `.notify()`.
1531
1532 /// Register a callable tool at runtime, exactly like [`Self::tool`] but
1533 /// without needing to own the server (and usable after it's already
1534 /// serving requests). Pushes `notifications/tools/list_changed`.
1535 ///
1536 /// ```rust
1537 /// use rust_web_server::mcp::{McpContent, McpServer};
1538 ///
1539 /// let server = McpServer::new("my-server", "1.0");
1540 ///
1541 /// // Later, from any thread holding a clone of `server`:
1542 /// server.register_tool("refresh_cache", "Reload the in-memory cache", "{}", |_args| {
1543 /// Ok(McpContent::text("cache refreshed"))
1544 /// });
1545 /// let existed = server.remove_tool("refresh_cache");
1546 /// assert!(existed);
1547 /// ```
1548 pub fn register_tool<F>(&self, name: &str, description: &str, input_schema: &str, handler: F)
1549 where
1550 F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
1551 {
1552 self.tools.write().unwrap().push(ToolDef {
1553 name: name.to_string(),
1554 description: description.to_string(),
1555 input_schema: input_schema.to_string(),
1556 annotations: None,
1557 meta: ToolMeta::default(),
1558 handler: Arc::new(move |_ctx: McpContext, args: &str| handler(args)),
1559 });
1560 self.notify("notifications/tools/list_changed", None);
1561 }
1562
1563 /// Register an `async fn` tool handler at runtime, exactly like
1564 /// [`Self::async_tool`] but usable after the server is already serving
1565 /// requests. Requires the `http2` feature — see `async_tool`'s docs for
1566 /// the async-to-sync bridging details.
1567 #[cfg(feature = "http2")]
1568 pub fn register_async_tool<F, Fut>(&self, name: &str, description: &str, input_schema: &str, handler: F)
1569 where
1570 F: Fn(&str) -> Fut + Send + Sync + 'static,
1571 Fut: Future<Output = Result<McpContent, String>> + Send + 'static,
1572 {
1573 self.async_tools.write().unwrap().push(AsyncToolDef {
1574 name: name.to_string(),
1575 description: description.to_string(),
1576 input_schema: input_schema.to_string(),
1577 annotations: None,
1578 meta: ToolMeta::default(),
1579 handler: Arc::new(move |args: String| Box::pin(handler(&args))),
1580 });
1581 self.notify("notifications/tools/list_changed", None);
1582 }
1583
1584 /// Remove a previously-registered tool by name — a sync tool registered
1585 /// via [`Self::tool`]/[`Self::register_tool`], or (with the `http2`
1586 /// feature) an async one via [`Self::async_tool`]/[`Self::register_async_tool`];
1587 /// both collections are checked. Returns `true` if a tool with that name
1588 /// existed in either and was removed. Pushes `notifications/tools/list_changed`
1589 /// only when something was actually removed.
1590 pub fn remove_tool(&self, name: &str) -> bool {
1591 #[cfg_attr(not(feature = "http2"), allow(unused_mut))]
1592 let mut removed = {
1593 let mut tools = self.tools.write().unwrap();
1594 let before = tools.len();
1595 tools.retain(|t| t.name != name);
1596 tools.len() != before
1597 };
1598
1599 #[cfg(feature = "http2")]
1600 {
1601 let mut async_tools = self.async_tools.write().unwrap();
1602 let before = async_tools.len();
1603 async_tools.retain(|t| t.name != name);
1604 removed = removed || async_tools.len() != before;
1605 }
1606
1607 if removed {
1608 self.notify("notifications/tools/list_changed", None);
1609 }
1610 removed
1611 }
1612
1613 /// Register a readable resource at runtime, exactly like [`Self::resource`].
1614 /// Pushes `notifications/resources/list_changed`.
1615 pub fn register_resource<F>(&self, uri_template: &str, name: &str, description: &str, handler: F)
1616 where
1617 F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
1618 {
1619 self.resources.write().unwrap().push(ResourceDef {
1620 uri_template: uri_template.to_string(),
1621 name: name.to_string(),
1622 description: description.to_string(),
1623 handler: Arc::new(handler),
1624 });
1625 self.notify("notifications/resources/list_changed", None);
1626 }
1627
1628 /// Remove a previously-registered resource by its exact `uri_template`
1629 /// (the same string passed to [`Self::register_resource`]/[`Self::resource`],
1630 /// not a concrete URI). Returns `true` if it existed. Pushes
1631 /// `notifications/resources/list_changed` only when something was removed.
1632 pub fn remove_resource(&self, uri_template: &str) -> bool {
1633 let removed = {
1634 let mut resources = self.resources.write().unwrap();
1635 let before = resources.len();
1636 resources.retain(|r| r.uri_template != uri_template);
1637 resources.len() != before
1638 };
1639 if removed {
1640 self.notify("notifications/resources/list_changed", None);
1641 }
1642 removed
1643 }
1644
1645 /// Register a prompt template at runtime, exactly like [`Self::prompt`]
1646 /// (no argument definitions — use [`Self::remove_prompt`] +
1647 /// [`Self::register_prompt`] if you need to change a prompt's arguments
1648 /// later; there is no dynamic equivalent of [`Self::prompt_with_args`]).
1649 /// Pushes `notifications/prompts/list_changed`.
1650 pub fn register_prompt<F>(&self, name: &str, description: &str, handler: F)
1651 where
1652 F: Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync + 'static,
1653 {
1654 self.prompts.write().unwrap().push(PromptDef {
1655 name: name.to_string(),
1656 description: description.to_string(),
1657 arguments: vec![],
1658 handler: Arc::new(handler),
1659 });
1660 self.notify("notifications/prompts/list_changed", None);
1661 }
1662
1663 /// Remove a previously-registered prompt by name. Returns `true` if it
1664 /// existed. Pushes `notifications/prompts/list_changed` only when
1665 /// something was removed.
1666 pub fn remove_prompt(&self, name: &str) -> bool {
1667 let removed = {
1668 let mut prompts = self.prompts.write().unwrap();
1669 let before = prompts.len();
1670 prompts.retain(|p| p.name != name);
1671 prompts.len() != before
1672 };
1673 if removed {
1674 self.notify("notifications/prompts/list_changed", None);
1675 }
1676 removed
1677 }
1678
1679 /// Require a bearer token on every request to the MCP endpoint.
1680 ///
1681 /// The client must send `Authorization: Bearer <token>`. Requests with a
1682 /// missing or wrong token receive `401 Unauthorized` before any JSON-RPC
1683 /// processing occurs.
1684 ///
1685 /// Store the token in an environment variable — never hard-code it:
1686 ///
1687 /// ```rust,no_run
1688 /// use rust_web_server::app::App;
1689 /// use rust_web_server::core::New;
1690 ///
1691 /// let app = App::new()
1692 /// .mcp("my-server", "1.0")
1693 /// .require_bearer(std::env::var("MCP_TOKEN").expect("MCP_TOKEN not set"));
1694 /// ```
1695 ///
1696 /// Claude Desktop config:
1697 /// ```json
1698 /// { "mcpServers": { "my-server": {
1699 /// "url": "http://localhost:7878/mcp",
1700 /// "headers": { "Authorization": "Bearer <token>" }
1701 /// }}}
1702 /// ```
1703 ///
1704 /// An empty token refuses every request (fails closed) rather than
1705 /// accepting an empty `Authorization: Bearer` (since 17.113.1). Tokens
1706 /// are compared in constant time.
1707 pub fn require_bearer(mut self, token: impl Into<String>) -> Self {
1708 self.auth_token = Some(token.into());
1709 self
1710 }
1711
1712 /// Accept several bearer tokens, each naming a caller: a request with
1713 /// `Authorization: Bearer <token>` is that caller, and handlers see
1714 /// `{"sub":"<identity>"}` as [`McpContext::auth_claims`] — what
1715 /// [`Self::tool_filter`] and the [gateway](gateway::McpGateway) decide
1716 /// by. Any other token gets `401`. Combines with
1717 /// [`Self::require_bearer`] (whose token carries no identity); OAuth
1718 /// ([`Self::require_oauth`]) replaces both. Empty tokens never match;
1719 /// tokens are compared in constant time.
1720 ///
1721 /// ```rust
1722 /// use rust_web_server::mcp::McpServer;
1723 ///
1724 /// let server = McpServer::new("my-server", "1.0")
1725 /// .require_bearer_tokens([("token-for-alice", "alice"), ("token-for-ci", "ci")]);
1726 /// ```
1727 pub fn require_bearer_tokens<I, T, N>(mut self, tokens: I) -> Self
1728 where
1729 I: IntoIterator<Item = (T, N)>,
1730 T: Into<String>,
1731 N: Into<String>,
1732 {
1733 self.bearer_tokens.extend(tokens.into_iter().map(|(token, identity)| (token.into(), identity.into())));
1734 self
1735 }
1736
1737 /// Show and allow only the tools `allow(ctx, tool_name)` accepts for a
1738 /// request: `tools/list` leaves the others out, and `tools/call` answers
1739 /// them as it would an unknown tool. `ctx.auth_claims` identifies the
1740 /// caller ([`Self::require_bearer_tokens`], [`Self::require_oauth`]).
1741 ///
1742 /// ```rust
1743 /// use rust_web_server::mcp::McpServer;
1744 ///
1745 /// let server = McpServer::new("my-server", "1.0")
1746 /// .tool_filter(|ctx, tool| !tool.starts_with("admin_") || ctx.auth_claims.as_deref() == Some(r#"{"sub":"root"}"#));
1747 /// ```
1748 pub fn tool_filter<F>(mut self, allow: F) -> Self
1749 where
1750 F: Fn(&McpContext, &str) -> bool + Send + Sync + 'static,
1751 {
1752 self.tool_filter = Some(Arc::new(allow));
1753 self
1754 }
1755
1756 fn tool_allowed(&self, ctx: &McpContext, name: &str) -> bool {
1757 self.tool_filter.as_ref().is_none_or(|allow| allow(ctx, name))
1758 }
1759
1760 /// Checks a request's bearer token against [`Self::require_bearer`] and
1761 /// [`Self::require_bearer_tokens`]: `Ok(claims)` (`Some` for a named
1762 /// token), or the `401` to send.
1763 fn check_bearer(&self, request: &Request) -> Result<Option<String>, Response> {
1764 if self.auth_token.is_none() && self.bearer_tokens.is_empty() {
1765 return Ok(None);
1766 }
1767 let provided = request.headers.iter()
1768 .find(|h| h.name.eq_ignore_ascii_case("authorization"))
1769 .map(|h| h.value.as_ref())
1770 .unwrap_or("");
1771 let bearer = provided.strip_prefix("Bearer ").unwrap_or("");
1772 // Compare against every token so the time taken doesn't tell which one matched.
1773 let mut identity = None;
1774 for (token, name) in &self.bearer_tokens {
1775 if bearer_matches(token, bearer) && identity.is_none() {
1776 identity = Some(name);
1777 }
1778 }
1779 if let Some(identity) = identity {
1780 return Ok(Some(format!(r#"{{"sub":"{}"}}"#, json_escape(identity))));
1781 }
1782 match &self.auth_token {
1783 Some(expected) if bearer_matches(expected, bearer) => Ok(None),
1784 _ => Err(unauthorized()),
1785 }
1786 }
1787
1788 /// Browser origins allowed to call the MCP endpoint, e.g.
1789 /// `["https://inspector.example.com"]`. A request carrying an `Origin`
1790 /// header that isn't listed gets `403` (the MCP transport spec requires
1791 /// validating `Origin`: without it a web page — or a DNS-rebinding
1792 /// attack — can drive a local MCP server from the visitor's browser).
1793 /// Requests without `Origin` (CLI and agent clients) are unaffected.
1794 /// Empty by default: no browser origin is allowed (since 17.113.1).
1795 pub fn allowed_origins<I, S>(mut self, origins: I) -> Self
1796 where
1797 I: IntoIterator<Item = S>,
1798 S: Into<String>,
1799 {
1800 self.allowed_origins = origins.into_iter().map(Into::into).collect();
1801 self
1802 }
1803
1804 /// Require an OAuth 2.0 / OIDC bearer JWT — verified against a live
1805 /// JWKS endpoint — on every request to the MCP endpoint (MCP
1806 /// 2025-03-26's OAuth 2.0 authorization requirement), instead of one
1807 /// static shared secret. Requires the `sso` feature.
1808 ///
1809 /// Reuses [`crate::sso::jwks::JwksCache`] directly — the "leverage
1810 /// point" this feature was scoped around — rather than reimplementing
1811 /// JWT/JWKS verification. `provider` supplies the issuer, JWKS URI, and
1812 /// (for `GET /.well-known/oauth-protected-resource`, RFC 9728) the
1813 /// authorization server's issuer; `audience` is the value tokens must
1814 /// carry in `aud` — ideally this server's canonical URL
1815 /// (`https://mcp.example.com/mcp`, RFC 8707), which is then also the
1816 /// metadata's `resource`; use a preset
1817 /// (`OidcProvider::google()`, etc.) or `OidcProvider::discover(issuer)?`.
1818 ///
1819 /// A request with a missing, malformed, or invalid-signature bearer
1820 /// token gets `401` with `WWW-Authenticate: Bearer` before any JSON-RPC
1821 /// processing occurs — matching [`Self::require_bearer`]'s behavior. On
1822 /// success, the verified claims are available to `.tool_with_context()`
1823 /// handlers as JSON via [`McpContext::auth_claims`].
1824 ///
1825 /// If both this and [`Self::require_bearer`] are configured, OAuth
1826 /// verification takes precedence and the static token is never checked
1827 /// — configuring both is not a supported combination, just an
1828 /// unambiguous tie-break for an unusual configuration.
1829 ///
1830 /// ```rust,no_run
1831 /// use rust_web_server::app::App;
1832 /// use rust_web_server::core::New;
1833 /// use rust_web_server::sso::OidcProvider;
1834 ///
1835 /// let app = App::new()
1836 /// .mcp("my-server", "1.0")
1837 /// .require_oauth(OidcProvider::google(), "my-mcp-client-id");
1838 /// ```
1839 #[cfg(feature = "sso")]
1840 pub fn require_oauth(mut self, provider: crate::sso::OidcProvider, audience: impl Into<String>) -> Self {
1841 self.oauth = Some(Arc::new(OAuthConfig {
1842 jwks: crate::sso::jwks::JwksCache::new(&provider.jwks_uri),
1843 provider,
1844 audience: audience.into(),
1845 }));
1846 self
1847 }
1848
1849 /// Wrap an existing [`Application`] so that non-MCP requests are forwarded
1850 /// to it instead of the built-in [`App`].
1851 ///
1852 /// Use this when your existing server has custom routes, state, or
1853 /// middleware that you want to keep alongside the MCP endpoint:
1854 ///
1855 /// ```rust,no_run
1856 /// use rust_web_server::app::App;
1857 /// use rust_web_server::mcp::{McpServer, McpContent};
1858 /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
1859 /// use rust_web_server::test_client::TestClient;
1860 ///
1861 /// let existing_app = App::with_state(42u32)
1862 /// .get("/api/hello", |_req, _params, _conn, _state| {
1863 /// Response::get_response(&STATUS_CODE_REASON_PHRASE.n200_ok, None, None)
1864 /// });
1865 ///
1866 /// let server = McpServer::new("my-app", "1.0")
1867 /// .tool("ping", "Ping", "{}", |_| Ok(McpContent::text("pong")))
1868 /// .wrap(existing_app);
1869 ///
1870 /// // Both /mcp and /api/hello are now handled by the same server.
1871 /// let client = TestClient::new(server);
1872 /// ```
1873 pub fn wrap(mut self, app: impl Application + Send + Sync + 'static) -> Self {
1874 self.fallback = Some(Arc::new(app));
1875 self
1876 }
1877
1878 /// Override the HTTP path for the MCP endpoint (default `"/mcp"`).
1879 pub fn at(mut self, path: impl Into<String>) -> Self {
1880 self.path = path.into();
1881 self
1882 }
1883
1884 /// Register a callable tool.
1885 ///
1886 /// - `name` — tool identifier (snake_case recommended)
1887 /// - `description` — human-readable description shown to the AI
1888 /// - `input_schema` — JSON Schema object for the tool's arguments
1889 /// - `handler` — closure receiving the raw `arguments` JSON string
1890 ///
1891 /// The handler returns [`McpContent`] on success or an error string. An
1892 /// error is returned to the client as `isError: true` (not a protocol error).
1893 ///
1894 /// Use [`Self::tool_with_context`] instead if the handler needs the
1895 /// caller's identity, session, or headers.
1896 pub fn tool<F>(self, name: &str, description: &str, input_schema: &str, handler: F) -> Self
1897 where
1898 F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
1899 {
1900 self.tools.write().unwrap().push(ToolDef {
1901 name: name.to_string(),
1902 description: description.to_string(),
1903 input_schema: input_schema.to_string(),
1904 annotations: None,
1905 meta: ToolMeta::default(),
1906 handler: Arc::new(move |_ctx: McpContext, args: &str| handler(args)),
1907 });
1908 self
1909 }
1910
1911 /// Register an `async fn` tool handler — for a tool whose work is
1912 /// naturally async (an `AsyncClient` HTTP call, an async database
1913 /// query, awaiting another future) rather than forcing it through a
1914 /// blocking call inside a plain [`Self::tool`] handler.
1915 ///
1916 /// Requires the `http2` feature (tokio). `tools/call` bridges into the
1917 /// handler's future via [`crate::async_bridge::block_on_isolated`] —
1918 /// the same mechanism [`crate::proxy::H2ReverseProxy`] and
1919 /// [`crate::async_state::AsyncAppWithState`] already use to call async
1920 /// code from a synchronous [`Application::execute`] — rather than
1921 /// `tokio::task::block_in_place`, which panics outside a `multi_thread`
1922 /// runtime; `block_on_isolated` works under any runtime flavor,
1923 /// including `current_thread`.
1924 ///
1925 /// Like [`Self::tool`] (not [`Self::tool_with_context`]), the handler
1926 /// only receives `arguments` — there is no async equivalent of
1927 /// `tool_with_context` or `tool_annotated` yet.
1928 ///
1929 /// ```rust,no_run
1930 /// # #[cfg(feature = "http2")]
1931 /// # {
1932 /// use rust_web_server::mcp::{McpContent, McpServer};
1933 ///
1934 /// let server = McpServer::new("my-server", "1.0")
1935 /// .async_tool("call_api", "Call an external API", "{}", |_args: &str| async move {
1936 /// Ok(McpContent::text("response"))
1937 /// });
1938 /// # }
1939 /// ```
1940 #[cfg(feature = "http2")]
1941 pub fn async_tool<F, Fut>(self, name: &str, description: &str, input_schema: &str, handler: F) -> Self
1942 where
1943 F: Fn(&str) -> Fut + Send + Sync + 'static,
1944 Fut: Future<Output = Result<McpContent, String>> + Send + 'static,
1945 {
1946 self.async_tools.write().unwrap().push(AsyncToolDef {
1947 name: name.to_string(),
1948 description: description.to_string(),
1949 input_schema: input_schema.to_string(),
1950 annotations: None,
1951 meta: ToolMeta::default(),
1952 handler: Arc::new(move |args: String| Box::pin(handler(&args))),
1953 });
1954 self
1955 }
1956
1957 /// Register a callable tool with [`ToolAnnotations`] — behavioral hints
1958 /// (read-only, destructive, idempotent, open-world) that MCP clients use
1959 /// to decide whether to warn or confirm before calling it. Otherwise
1960 /// identical to [`Self::tool`] — the handler still only receives
1961 /// `arguments`, not [`McpContext`] (there is currently no single builder
1962 /// combining annotations with per-request context; call [`Self::tool_with_context`]
1963 /// instead if you need context and don't need annotations).
1964 ///
1965 /// ```rust,no_run
1966 /// use rust_web_server::mcp::{McpContent, McpServer, ToolAnnotations};
1967 ///
1968 /// let server = McpServer::new("my-server", "1.0")
1969 /// .tool_annotated(
1970 /// "delete_file",
1971 /// "Delete a file from disk",
1972 /// r#"{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"#,
1973 /// ToolAnnotations {
1974 /// destructive_hint: Some(true),
1975 /// read_only_hint: Some(false),
1976 /// idempotent_hint: Some(true), // deleting twice = deleting once
1977 /// ..Default::default()
1978 /// },
1979 /// |_args| Ok(McpContent::text("deleted")),
1980 /// );
1981 /// ```
1982 pub fn tool_annotated<F>(
1983 self,
1984 name: &str,
1985 description: &str,
1986 input_schema: &str,
1987 annotations: ToolAnnotations,
1988 handler: F,
1989 ) -> Self
1990 where
1991 F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
1992 {
1993 self.tools.write().unwrap().push(ToolDef {
1994 name: name.to_string(),
1995 description: description.to_string(),
1996 input_schema: input_schema.to_string(),
1997 annotations: Some(annotations),
1998 meta: ToolMeta::default(),
1999 handler: Arc::new(move |_ctx: McpContext, args: &str| handler(args)),
2000 });
2001 self
2002 }
2003
2004 /// Register a callable tool whose handler also receives [`McpContext`] —
2005 /// caller identity/session info derived from this request's headers and
2006 /// whatever `clientInfo` this session sent at `initialize` time.
2007 ///
2008 /// Same `name`/`description`/`input_schema` semantics as [`Self::tool`];
2009 /// the only difference is the handler's first parameter.
2010 ///
2011 /// ```rust,no_run
2012 /// use rust_web_server::mcp::{McpContent, McpServer};
2013 ///
2014 /// let server = McpServer::new("my-server", "1.0")
2015 /// .tool_with_context(
2016 /// "whoami",
2017 /// "Report the caller's client info",
2018 /// "{}",
2019 /// |ctx, _args| {
2020 /// let name = ctx.client_name.as_deref().unwrap_or("unknown client");
2021 /// Ok(McpContent::text(format!("Called by {name}")))
2022 /// },
2023 /// );
2024 /// ```
2025 pub fn tool_with_context<F>(self, name: &str, description: &str, input_schema: &str, handler: F) -> Self
2026 where
2027 F: Fn(McpContext, &str) -> Result<McpContent, String> + Send + Sync + 'static,
2028 {
2029 self.tools.write().unwrap().push(ToolDef {
2030 name: name.to_string(),
2031 description: description.to_string(),
2032 input_schema: input_schema.to_string(),
2033 annotations: None,
2034 meta: ToolMeta::default(),
2035 handler: Arc::new(handler),
2036 });
2037 self
2038 }
2039
2040 /// Register a tool whose arguments and result are Rust types: the
2041 /// `inputSchema` comes from `In` and the `outputSchema` from `Out`
2042 /// ([`JsonSchema`](crate::schema::JsonSchema), usually
2043 /// `#[derive(JsonSchema)]`), the arguments are deserialized into `In`, and
2044 /// the handler's `Out` is sent as `structuredContent` (and as JSON text
2045 /// for older clients). Requires the `serde` feature.
2046 ///
2047 /// Arguments that don't deserialize come back as an `isError` result
2048 /// naming the problem, as do the handler's errors. MCP requires an
2049 /// `outputSchema` to describe an object, so an `Out` that isn't a struct
2050 /// is sent as JSON text without one.
2051 ///
2052 /// ```rust,ignore
2053 /// use rust_web_server::JsonSchema;
2054 /// use serde::{Deserialize, Serialize};
2055 ///
2056 /// #[derive(Deserialize, JsonSchema)]
2057 /// struct Lookup { sku: String }
2058 ///
2059 /// #[derive(Serialize, JsonSchema)]
2060 /// struct Stock { sku: String, available: u32 }
2061 ///
2062 /// let server = McpServer::new("shop", "1.0")
2063 /// .typed_tool("stock", "Units available for a SKU", |_ctx, args: Lookup| {
2064 /// Ok(Stock { available: 3, sku: args.sku })
2065 /// });
2066 /// ```
2067 #[cfg(feature = "serde")]
2068 pub fn typed_tool<In, Out, F>(self, name: &str, description: &str, handler: F) -> Self
2069 where
2070 In: serde::de::DeserializeOwned + crate::schema::JsonSchema,
2071 Out: serde::Serialize + crate::schema::JsonSchema,
2072 F: Fn(McpContext, In) -> Result<Out, String> + Send + Sync + 'static,
2073 {
2074 let output_schema = Out::json_schema();
2075 let structured = crate::schema::is_object_schema(&output_schema);
2076 let server = self.tool_with_context(name, description, &In::json_schema(), move |ctx, args| {
2077 let args = if args.trim().is_empty() { "{}" } else { args };
2078 let input: In = serde_json::from_str(args).map_err(|e| format!("Invalid arguments: {e}"))?;
2079 let output = handler(ctx, input)?;
2080 let json = serde_json::to_string(&output).map_err(|e| format!("Could not serialize the result: {e}"))?;
2081 Ok(if structured { McpContent::structured(json) } else { McpContent::json(json) })
2082 });
2083 if structured {
2084 server.tool_output_schema(name, &output_schema)
2085 } else {
2086 server
2087 }
2088 }
2089
2090 /// Register a readable resource.
2091 ///
2092 /// `uri_template` uses `{param}` placeholders, e.g. `"user://{id}"`.
2093 /// The handler receives the full concrete URI string.
2094 pub fn resource<F>(self, uri_template: &str, name: &str, description: &str, handler: F) -> Self
2095 where
2096 F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
2097 {
2098 self.resources.write().unwrap().push(ResourceDef {
2099 uri_template: uri_template.to_string(),
2100 name: name.to_string(),
2101 description: description.to_string(),
2102 handler: Arc::new(handler),
2103 });
2104 self
2105 }
2106
2107 /// Register a prompt template.
2108 ///
2109 /// The handler receives the raw `arguments` JSON string and returns a
2110 /// list of [`PromptMessage`] values.
2111 pub fn prompt<F>(self, name: &str, description: &str, handler: F) -> Self
2112 where
2113 F: Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync + 'static,
2114 {
2115 self.prompts.write().unwrap().push(PromptDef {
2116 name: name.to_string(),
2117 description: description.to_string(),
2118 arguments: vec![],
2119 handler: Arc::new(handler),
2120 });
2121 self
2122 }
2123
2124 /// Register a prompt template with explicit argument definitions.
2125 pub fn prompt_with_args<F>(
2126 self,
2127 name: &str,
2128 description: &str,
2129 args: Vec<PromptArgDef>,
2130 handler: F,
2131 ) -> Self
2132 where
2133 F: Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync + 'static,
2134 {
2135 self.prompts.write().unwrap().push(PromptDef {
2136 name: name.to_string(),
2137 description: description.to_string(),
2138 arguments: args,
2139 handler: Arc::new(handler),
2140 });
2141 self
2142 }
2143
2144 /// Register an argument-completion provider for one named argument of a
2145 /// tool or prompt, so clients like Cursor and VS Code can offer
2146 /// autocomplete while the user fills in that argument.
2147 ///
2148 /// `ref_type` is `"tool"` or `"prompt"` — matched against the incoming
2149 /// `completion/complete` request's `ref.type` (`"ref/tool"`/`"ref/prompt"`
2150 /// on the wire) with the `"ref/"` prefix stripped. `ref_name` is the
2151 /// tool or prompt name this applies to. The handler receives the
2152 /// argument's name and whatever partial value the user has typed so
2153 /// far, and returns candidate completion strings (or an error, mapped
2154 /// to a JSON-RPC `INVALID_PARAMS` response).
2155 ///
2156 /// `initialize` advertises the `completions` capability automatically
2157 /// once at least one `.completion()` has been registered — there's no
2158 /// separate opt-in flag to remember.
2159 ///
2160 /// ```rust
2161 /// use rust_web_server::mcp::McpServer;
2162 ///
2163 /// let server = McpServer::new("my-server", "1.0")
2164 /// .completion("tool", "deploy", |arg_name, _partial| {
2165 /// match arg_name {
2166 /// "region" => Ok(vec!["us-east-1".to_string(), "eu-west-1".to_string()]),
2167 /// _ => Ok(vec![]),
2168 /// }
2169 /// });
2170 /// ```
2171 pub fn completion<F>(self, ref_type: &str, ref_name: &str, handler: F) -> Self
2172 where
2173 F: Fn(&str, &str) -> Result<Vec<String>, String> + Send + Sync + 'static,
2174 {
2175 self.completions.write().unwrap().push(CompletionDef {
2176 ref_type: ref_type.to_string(),
2177 ref_name: ref_name.to_string(),
2178 handler: Arc::new(handler),
2179 });
2180 self
2181 }
2182
2183 // ── request dispatch ──────────────────────────────────────────────────────
2184
2185 /// Process a raw JSON-RPC body and return an HTTP response.
2186 ///
2187 /// Equivalent to [`Self::handle_request_with_context`] with an empty
2188 /// [`McpContext`] — tool handlers registered via
2189 /// [`Self::tool_with_context`] will see every field as `None`. Prefer
2190 /// calling through [`Application::execute`] (i.e. actually serving HTTP
2191 /// requests) when you need real per-request context; this method exists
2192 /// for calling the JSON-RPC layer directly, e.g. in tests.
2193 pub fn handle_request(&self, body: &str) -> Response {
2194 self.handle_request_with_context(body, McpContext::default())
2195 }
2196
2197 /// Process a raw JSON-RPC body with an explicit [`McpContext`] and return
2198 /// an HTTP response. [`Self::execute`] calls this with a context built
2199 /// from the request's headers and this session's stored `clientInfo`;
2200 /// [`Self::handle_request`] calls this with an empty context.
2201 ///
2202 /// On a successful `initialize`, this mints a new session id (reusing
2203 /// [`crate::request_id::generate_request_id`]'s ID generator), records
2204 /// `params.clientInfo` under it, and returns the id in an
2205 /// `Mcp-Session-Id` response header — the client is expected to echo that
2206 /// header back on subsequent requests so later `tools/call`s in the same
2207 /// session can look their `clientInfo` back up.
2208 pub fn handle_request_with_context(&self, body: &str, ctx: McpContext) -> Response {
2209 let trimmed = body.trim_start();
2210 if trimmed.starts_with('[') {
2211 return self.handle_batch(trimmed, ctx);
2212 }
2213
2214 let id = json_rpc::extract_id(body);
2215
2216 let method = match json_rpc::extract_str(body, "method") {
2217 Some(m) => m,
2218 None => {
2219 // No `method` — either malformed, or a JSON-RPC *response*
2220 // to a sampling/createMessage request this server sent
2221 // earlier (responses never carry `method`).
2222 if let Some(id) = &id {
2223 if self.try_deliver_sampling_response(id, body) {
2224 return no_content();
2225 }
2226 }
2227 return rpc_error(None, json_rpc::INVALID_REQUEST, "Missing method");
2228 }
2229 };
2230
2231 if method == "notifications/cancelled" {
2232 self.handle_cancellation(body);
2233 return no_content();
2234 }
2235
2236 if method == "notifications/roots/list_changed" {
2237 self.invalidate_roots_cache(&ctx.session_id);
2238 return no_content();
2239 }
2240
2241 // Notifications have no `id` — acknowledge with 202 and no body.
2242 if method == "notifications/initialized" || (id.is_none() && method != "ping") {
2243 return no_content();
2244 }
2245
2246 let result = self.dispatch_with_cancellation(&method, body, ctx, &id);
2247 let id_str = id.as_deref().unwrap_or("null");
2248 let is_ok = result.is_ok();
2249
2250 let mut response = json_response(&Self::format_result(id_str, &result));
2251
2252 if method == "initialize" && is_ok {
2253 self.start_session(body, &mut response);
2254 }
2255
2256 response
2257 }
2258
2259 /// Process a JSON-RPC 2.0 batch request — a top-level JSON array of
2260 /// request objects sent in a single `POST /mcp` body, per the JSON-RPC
2261 /// batch spec that MCP inherits. Each element is dispatched exactly as
2262 /// [`Self::handle_request_with_context`] would dispatch it standalone;
2263 /// notifications (no `id`) contribute no entry to the response array,
2264 /// same as they'd get no response body outside a batch.
2265 ///
2266 /// An empty array (`[]`) is itself an invalid request per the JSON-RPC
2267 /// spec, so it gets one `Invalid Request` error object back rather than
2268 /// an empty array. A batch made up entirely of notifications produces no
2269 /// response body at all (`202 Accepted`), matching a single notification.
2270 ///
2271 /// If the batch contains a successful `initialize` call, the *first* one
2272 /// mints a session and attaches `Mcp-Session-Id` to the overall response,
2273 /// same as a standalone `initialize` would — sending more than one
2274 /// `initialize` in a batch is unusual and only the first is honored for
2275 /// session purposes, since one HTTP response can only carry one session id.
2276 fn handle_batch(&self, array_body: &str, ctx: McpContext) -> Response {
2277 let elements = json_rpc::split_array_elements(array_body);
2278 if elements.is_empty() {
2279 return rpc_error(None, json_rpc::INVALID_REQUEST, "Invalid Request");
2280 }
2281
2282 let mut parts: Vec<String> = Vec::new();
2283 let mut session_init_body: Option<String> = None;
2284
2285 for elem in &elements {
2286 let id = json_rpc::extract_id(elem);
2287
2288 let method = match json_rpc::extract_str(elem, "method") {
2289 Some(m) => m,
2290 None => {
2291 if let Some(id) = &id {
2292 if self.try_deliver_sampling_response(id, elem) {
2293 continue; // delivered; no response entry, same as any notification
2294 }
2295 }
2296 parts.push(Self::format_result(
2297 "null",
2298 &Err((json_rpc::INVALID_REQUEST, "Missing method".to_string())),
2299 ));
2300 continue;
2301 }
2302 };
2303
2304 if method == "notifications/cancelled" {
2305 self.handle_cancellation(elem);
2306 continue;
2307 }
2308
2309 if method == "notifications/roots/list_changed" {
2310 self.invalidate_roots_cache(&ctx.session_id);
2311 continue;
2312 }
2313
2314 if method == "notifications/initialized" || (id.is_none() && method != "ping") {
2315 continue;
2316 }
2317
2318 let result = self.dispatch_with_cancellation(&method, elem, ctx.clone(), &id);
2319 let id_str = id.as_deref().unwrap_or("null");
2320 let is_ok = result.is_ok();
2321
2322 if method == "initialize" && is_ok && session_init_body.is_none() {
2323 session_init_body = Some(elem.clone());
2324 }
2325
2326 parts.push(Self::format_result(id_str, &result));
2327 }
2328
2329 if parts.is_empty() {
2330 // Every element was a notification — no response body, same as a
2331 // single standalone notification.
2332 return no_content();
2333 }
2334
2335 let mut response = json_response(&format!("[{}]", parts.join(",")));
2336 if let Some(init_body) = session_init_body {
2337 self.start_session(&init_body, &mut response);
2338 }
2339 response
2340 }
2341
2342 /// Dispatch one already-parsed JSON-RPC `method` against `body` (the raw
2343 /// single-object message, whether it arrived standalone or as one element
2344 /// of a batch) and return the JSON-RPC `result` payload or an error.
2345 /// Shared by [`Self::handle_request_with_context`] and [`Self::handle_batch`]
2346 /// so the method table exists in exactly one place.
2347 fn dispatch(&self, method: &str, body: &str, ctx: McpContext) -> Result<String, (i32, String)> {
2348 match method {
2349 "initialize" => self.do_initialize(body),
2350 "ping" => Ok("{}".to_string()),
2351 "tools/list" => self.do_tools_list(body, &ctx),
2352 "tools/call" => self.do_tools_call(body, ctx),
2353 "resources/list" => self.do_resources_list(body),
2354 "resources/read" => self.do_resources_read(body),
2355 "resources/subscribe" => self.do_resource_subscribe(body, ctx.session_id),
2356 "resources/unsubscribe" => self.do_resource_unsubscribe(body, ctx.session_id),
2357 "prompts/list" => self.do_prompts_list(body),
2358 "prompts/get" => self.do_prompts_get(body),
2359 "logging/setLevel" => self.do_set_log_level(body),
2360 "completion/complete" => self.do_completion(body),
2361 _ => Err((json_rpc::METHOD_NOT_FOUND, format!("Unknown method: {method}"))),
2362 }
2363 }
2364
2365 /// Wraps [`Self::dispatch`] for one request, additionally registering a
2366 /// cancellation flag for `tools/call` (keyed by `id`, guaranteed `Some`
2367 /// for `tools/call` — a notification-shaped `tools/call` with no `id`
2368 /// never reaches this far, see the notification checks in
2369 /// [`Self::handle_request_with_context`]/[`Self::handle_batch`]) so a
2370 /// later `notifications/cancelled` referencing this exact request can
2371 /// flip it. The entry is removed again once dispatch returns, whether
2372 /// the handler checked the flag or not — this map never accumulates
2373 /// stale entries the way `sessions`/`sse_clients` can.
2374 fn dispatch_with_cancellation(
2375 &self,
2376 method: &str,
2377 body: &str,
2378 ctx: McpContext,
2379 id: &Option<String>,
2380 ) -> Result<String, (i32, String)> {
2381 let Some(id_str) = (method == "tools/call").then_some(id.as_ref()).flatten() else {
2382 return self.dispatch(method, body, ctx);
2383 };
2384
2385 let flag = Arc::new(std::sync::atomic::AtomicBool::new(false));
2386 self.cancellations.lock().unwrap().insert(id_str.clone(), flag.clone());
2387 let ctx = McpContext { cancellation: Some(flag), ..ctx };
2388 let result = self.dispatch(method, body, ctx);
2389 self.cancellations.lock().unwrap().remove(id_str);
2390 result
2391 }
2392
2393 /// Handle `notifications/cancelled`: look up `params.requestId` (the
2394 /// spec allows `string | number`, so this is matched as a raw JSON
2395 /// token, the same way [`McpContext::progress_token`] is) in
2396 /// `cancellations` and flip its flag if the request is still in flight.
2397 /// Silently does nothing for an unknown/already-finished request id —
2398 /// the target call may simply have already completed naturally, which
2399 /// isn't an error.
2400 fn handle_cancellation(&self, body: &str) {
2401 let Some(params) = json_rpc::extract_raw(body, "params") else { return };
2402 let Some(request_id) = json_rpc::extract_raw(¶ms, "requestId") else { return };
2403 if let Some(flag) = self.cancellations.lock().unwrap().get(&request_id) {
2404 flag.store(true, std::sync::atomic::Ordering::Relaxed);
2405 }
2406 }
2407
2408 /// Handle `notifications/roots/list_changed`: clear this session's
2409 /// cached `roots/list` result (if any), so the next
2410 /// [`McpContext::list_roots`] call in this session does a fresh round
2411 /// trip instead of serving stale data. Correlated purely by this
2412 /// request's own `Mcp-Session-Id` (`session_id`) — unlike
2413 /// `notifications/cancelled`, this notification carries no params of
2414 /// its own to key off of; it's about "your cache for *this connection*
2415 /// is stale," not any other request.
2416 fn invalidate_roots_cache(&self, session_id: &Option<String>) {
2417 let Some(sid) = session_id else { return };
2418 if let Some(info) = self.sessions.lock().unwrap().get_mut(sid) {
2419 info.roots = None;
2420 }
2421 }
2422
2423 /// If `body` is a JSON-RPC *response* (no `method`, by definition — see
2424 /// [`Self::handle_request_with_context`]) to a `sampling/createMessage`
2425 /// request this server sent and is still waiting on (`id` found in
2426 /// `pending_replies`), deliver it to the blocked [`McpContext::sample`]
2427 /// call and return `true`. Returns `false` for any `id` this server
2428 /// isn't tracking — the caller still needs to treat that as a genuine
2429 /// malformed/unrecognized message.
2430 fn try_deliver_sampling_response(&self, id: &str, body: &str) -> bool {
2431 let sender = match self.pending_replies.lock().unwrap().remove(id) {
2432 Some(s) => s,
2433 None => return false,
2434 };
2435 let payload = match json_rpc::extract_raw(body, "result") {
2436 Some(result) => Ok(result),
2437 None => {
2438 let message = json_rpc::extract_raw(body, "error")
2439 .and_then(|e| json_rpc::extract_str(&e, "message"))
2440 .unwrap_or_else(|| "sampling/createMessage request failed".to_string());
2441 Err(message)
2442 }
2443 };
2444 let _ = sender.send(payload); // the waiter may have already timed out and dropped rx
2445 true
2446 }
2447
2448 /// Render one JSON-RPC 2.0 response object — `{"jsonrpc":"2.0","result":...,"id":...}`
2449 /// or the `error` shape — from a dispatch result and its request's `id` (already
2450 /// rendered as a raw JSON token, e.g. `"1"`, `"\"abc\""`, or `"null"`).
2451 fn format_result(id_str: &str, result: &Result<String, (i32, String)>) -> String {
2452 match result {
2453 Ok(result_json) => format!(
2454 r#"{{"jsonrpc":"2.0","result":{result_json},"id":{id_str}}}"#
2455 ),
2456 Err((code, msg)) => {
2457 let escaped = json_escape(msg);
2458 format!(
2459 r#"{{"jsonrpc":"2.0","error":{{"code":{code},"message":"{escaped}"}},"id":{id_str}}}"#
2460 )
2461 }
2462 }
2463 }
2464
2465 /// Mint a new session id, record `body`'s `params.clientInfo` under it
2466 /// (logging the caller's identity), and attach the id to `response` as
2467 /// an `Mcp-Session-Id` header. Called once, from
2468 /// [`Self::handle_request_with_context`], right after a successful
2469 /// `initialize`.
2470 fn start_session(&self, body: &str, response: &mut Response) {
2471 let params = json_rpc::extract_raw(body, "params");
2472 let client_info = params.as_deref().and_then(|p| json_rpc::extract_raw(p, "clientInfo"));
2473 let (name, version) = match &client_info {
2474 Some(info) => (
2475 json_rpc::extract_str(info, "name"),
2476 json_rpc::extract_str(info, "version"),
2477 ),
2478 None => (None, None),
2479 };
2480 let capabilities = params.as_deref().and_then(|p| json_rpc::extract_raw(p, "capabilities"));
2481 let supports_sampling = capabilities.as_deref()
2482 .and_then(|c| json_rpc::extract_raw(c, "sampling"))
2483 .is_some();
2484 let supports_roots = capabilities.as_deref()
2485 .and_then(|c| json_rpc::extract_raw(c, "roots"))
2486 .is_some();
2487
2488 eprintln!(
2489 "[mcp] initialize from client {} v{}",
2490 name.as_deref().unwrap_or("unknown"),
2491 version.as_deref().unwrap_or("unknown"),
2492 );
2493
2494 let session_id = crate::request_id::generate_request_id();
2495 self.make_room_for_a_session();
2496 self.sessions
2497 .lock()
2498 .unwrap()
2499 .insert(session_id.clone(), StoredClientInfo { last_seen: std::time::Instant::now(), protocol_version: negotiate_version(
2500 params.as_deref().and_then(|p| json_rpc::extract_str(p, "protocolVersion")).as_deref()), name, version, supports_sampling, supports_roots, roots: None });
2501
2502 response.headers.push(Header {
2503 name: "Mcp-Session-Id".into(),
2504 value: session_id.into(),
2505 });
2506 }
2507
2508 // ── method handlers ───────────────────────────────────────────────────────
2509
2510 /// Handle `initialize`. Per spec, the server must inspect the client's
2511 /// requested `protocolVersion` and respond with the version it actually
2512 /// supports — allowing the client to abort the session if incompatible —
2513 /// rather than blindly echoing `PROTOCOL_VERSION` regardless of what was
2514 /// asked for.
2515 ///
2516 /// A version in [`SUPPORTED_PROTOCOL_VERSIONS`] is confirmed; any other
2517 /// (older, newer, malformed) gets [`PROTOCOL_VERSION`], the newest. The
2518 /// session remembers the result, which decides e.g. whether JSON-RPC
2519 /// batches are accepted (only up to 2025-03-26).
2520 ///
2521 /// `clientInfo` is *not* handled here — [`Self::handle_request_with_context`]
2522 /// extracts and stores it (under a freshly minted session id) after this
2523 /// returns, so it's only ever parsed out of `body` once per call.
2524 fn do_initialize(&self, body: &str) -> Result<String, (i32, String)> {
2525 let params = json_rpc::extract_raw(body, "params");
2526
2527 let client_version = params.as_deref().and_then(|p| json_rpc::extract_str(p, "protocolVersion"));
2528
2529 let negotiated_version = negotiate_version(client_version.as_deref());
2530
2531 let logging_cap = if self.logging_enabled { r#","logging":{}"# } else { "" };
2532 // completions is advertised iff at least one .completion() has been registered —
2533 // no separate opt-in flag needed, unlike logging: if nothing was registered,
2534 // completion/complete would just return empty results for everything anyway.
2535 let completions_cap = if self.completions.read().unwrap().is_empty() { "" } else { r#","completions":{}"# };
2536 // listChanged is always true: register_tool/remove_tool (and the resource/prompt
2537 // equivalents) are always available, unlike logging which is opt-in via
2538 // .logging_enabled(). resources.subscribe is also always true now that
2539 // resources/subscribe and resources/unsubscribe are implemented (MCP_TODO.md's TODO-14).
2540 let caps = format!(
2541 r#"{{"tools":{{"listChanged":true}},"resources":{{"subscribe":true,"listChanged":true}},"prompts":{{"listChanged":true}}{logging_cap}{completions_cap}}}"#
2542 );
2543 Ok(format!(
2544 r#"{{"protocolVersion":"{}","capabilities":{caps},"serverInfo":{{"name":"{}"{}"version":"{}"{}}}}}"#,
2545 json_escape(negotiated_version),
2546 json_escape(&self.server_name),
2547 self.server_title.as_ref().map(|t| format!(r#","title":"{}","#, json_escape(t))).unwrap_or_else(|| ",".to_string()),
2548 json_escape(&self.server_version),
2549 icons_json(&self.server_icons),
2550 ))
2551 }
2552
2553 fn do_tools_list(&self, body: &str, ctx: &McpContext) -> Result<String, (i32, String)> {
2554 #[cfg_attr(not(feature = "http2"), allow(unused_mut))]
2555 let mut items: Vec<String> = self.tools.read().unwrap().iter()
2556 .filter(|t| self.tool_allowed(ctx, &t.name))
2557 .map(|t| Self::render_tool_list_entry(&t.name, &t.description, &t.input_schema, t.annotations, &t.meta))
2558 .collect();
2559
2560 #[cfg(feature = "http2")]
2561 items.extend(self.async_tools.read().unwrap().iter()
2562 .filter(|t| self.tool_allowed(ctx, &t.name))
2563 .map(|t| Self::render_tool_list_entry(&t.name, &t.description, &t.input_schema, t.annotations, &t.meta)));
2564
2565 let (page, next_cursor) = self.paginate(&items, body)?;
2566 Ok(format!(r#"{{"tools":[{}]{}}}"#, page.join(","), next_cursor_json(&next_cursor)))
2567 }
2568
2569 /// Render one `tools/list` entry — shared by the sync (`tools`) and
2570 /// (with the `http2` feature) async (`async_tools`) collections, which
2571 /// are otherwise identical from the client's point of view.
2572 fn render_tool_list_entry(name: &str, description: &str, input_schema: &str, annotations: Option<ToolAnnotations>, meta: &ToolMeta) -> String {
2573 let annotations_field = match annotations {
2574 Some(a) => format!(r#","annotations":{}"#, a.to_json()),
2575 None => String::new(),
2576 };
2577 let title = meta.title.as_ref().map(|t| format!(r#","title":"{}""#, json_escape(t))).unwrap_or_default();
2578 let output_schema = meta.output_schema.as_ref().map(|schema| format!(r#","outputSchema":{schema}"#)).unwrap_or_default();
2579 format!(
2580 r#"{{"name":"{}"{title},"description":"{}","inputSchema":{}{output_schema}{}{}}}"#,
2581 json_escape(name), json_escape(description), input_schema, annotations_field, icons_json(&meta.icons),
2582 )
2583 }
2584
2585 /// Check a `tools/call`'s `arguments` against the tool's input schema:
2586 /// an object, every `required` property present, and each declared
2587 /// property of the declared `type`. Not a full JSON Schema validator —
2588 /// the checks a model's mistakes actually trip. A failure becomes a tool
2589 /// result with `isError: true` (MCP 2025-11-25, SEP-1303), so the model
2590 /// sees why and can retry, instead of a protocol error it never reads.
2591 pub(crate) fn validate_arguments(schema: &str, args: &str) -> Result<(), String> {
2592 let Some(schema) = json_rpc::object_members(schema) else { return Ok(()) };
2593 let field = |name: &str| schema.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str());
2594 let Some(arguments) = json_rpc::object_members(args) else {
2595 return Err("Invalid arguments: expected a JSON object".to_string());
2596 };
2597 if let Some(required) = field("required") {
2598 for name in json_rpc::split_array_elements(required) {
2599 let name = name.trim().trim_matches('"');
2600 if !arguments.iter().any(|(k, _)| k == name) {
2601 return Err(format!("Invalid arguments: missing required property \"{name}\""));
2602 }
2603 }
2604 }
2605 let Some(properties) = field("properties").and_then(json_rpc::object_members) else { return Ok(()) };
2606 for (name, value) in &arguments {
2607 let Some((_, property)) = properties.iter().find(|(k, _)| k == name) else { continue };
2608 let Some(declared) = json_rpc::object_members(property)
2609 .and_then(|p| p.into_iter().find(|(k, _)| k == "type").map(|(_, v)| v))
2610 else { continue };
2611 let allowed: Vec<String> = if declared.starts_with('[') {
2612 json_rpc::split_array_elements(&declared).iter().map(|t| t.trim().trim_matches('"').to_string()).collect()
2613 } else {
2614 vec![declared.trim_matches('"').to_string()]
2615 };
2616 let actual = json_type(value);
2617 let fits = allowed.iter().any(|t| t == actual || (t == "number" && actual == "integer"));
2618 if !fits {
2619 return Err(format!("Invalid arguments: property \"{name}\" must be {}, got {actual}", allowed.join(" or ")));
2620 }
2621 }
2622 Ok(())
2623 }
2624
2625 fn do_tools_call(&self, body: &str, ctx: McpContext) -> Result<String, (i32, String)> {
2626 let params = json_rpc::extract_raw(body, "params")
2627 .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
2628 let name = json_rpc::extract_str(¶ms, "name")
2629 .ok_or((json_rpc::INVALID_PARAMS, "Missing tool name".to_string()))?;
2630 let args = json_rpc::extract_raw(¶ms, "arguments")
2631 .unwrap_or_else(|| "{}".to_string());
2632 if !self.tool_allowed(&ctx, &name) {
2633 return Err((json_rpc::INVALID_PARAMS, format!("Unknown tool: {name}")));
2634 }
2635
2636 // `_meta.progressToken` (string or number, per spec) — stored raw so
2637 // `McpContext::report_progress` can splice it back verbatim.
2638 let progress_token = json_rpc::extract_raw(¶ms, "_meta")
2639 .and_then(|meta| json_rpc::extract_raw(&meta, "progressToken"));
2640 let ctx = McpContext { progress_token, ..ctx };
2641
2642 let sync_handler = {
2643 let tools = self.tools.read().unwrap();
2644 tools.iter().find(|t| t.name == name).map(|t| (t.handler.clone(), t.input_schema.clone()))
2645 };
2646 if let Some((handler, schema)) = sync_handler {
2647 if let Err(invalid) = Self::validate_arguments(&schema, &args) {
2648 return Ok(Self::format_tool_result(Err(invalid)));
2649 }
2650 return Ok(Self::format_tool_result(handler(ctx, &args)));
2651 }
2652
2653 #[cfg(feature = "http2")]
2654 {
2655 let async_handler = {
2656 let async_tools = self.async_tools.read().unwrap();
2657 async_tools.iter().find(|t| t.name == name).map(|t| (t.handler.clone(), t.input_schema.clone()))
2658 };
2659 if let Some((handler, schema)) = async_handler {
2660 if let Err(invalid) = Self::validate_arguments(&schema, &args) {
2661 return Ok(Self::format_tool_result(Err(invalid)));
2662 }
2663 let args_owned = args.clone();
2664 let result = crate::async_bridge::block_on_isolated(move || handler(args_owned));
2665 return Ok(Self::format_tool_result(result));
2666 }
2667 }
2668
2669 Err((json_rpc::INVALID_PARAMS, format!("Unknown tool: {name}")))
2670 }
2671
2672 /// Render a tool handler's result as the spec's `tools/call` shape —
2673 /// `{"content":[...],"isError":false}` on success, or `isError:true`
2674 /// with the error message as a text content block. Shared by the sync
2675 /// and (with `http2`) async `tools/call` dispatch paths.
2676 fn format_tool_result(result: Result<McpContent, String>) -> String {
2677 match result {
2678 Ok(c) if c.kind == "result" => c.text,
2679 Ok(c) if c.kind == "blocks" => format!(r#"{{"content":[{}],"isError":false}}"#, c.text),
2680 Ok(c) if c.kind == "structured" => format!(
2681 r#"{{"content":[{{"type":"text","text":"{}"}}],"structuredContent":{},"isError":false}}"#,
2682 json_escape(&c.text),
2683 c.text,
2684 ),
2685 Ok(c) => format!(
2686 r#"{{"content":[{}],"isError":false}}"#,
2687 c.to_content_json(),
2688 ),
2689 Err(e) => {
2690 let escaped = json_escape(&e);
2691 format!(
2692 r#"{{"content":[{{"type":"text","text":"{escaped}"}}],"isError":true}}"#
2693 )
2694 }
2695 }
2696 }
2697
2698 fn do_resources_list(&self, body: &str) -> Result<String, (i32, String)> {
2699 let items: Vec<String> = self.resources.read().unwrap().iter().map(|r| {
2700 format!(
2701 r#"{{"uri":"{}","name":"{}","description":"{}","mimeType":"text/plain"}}"#,
2702 json_escape(&r.uri_template),
2703 json_escape(&r.name),
2704 json_escape(&r.description),
2705 )
2706 }).collect();
2707 let (page, next_cursor) = self.paginate(&items, body)?;
2708 Ok(format!(r#"{{"resources":[{}]{}}}"#, page.join(","), next_cursor_json(&next_cursor)))
2709 }
2710
2711 fn do_resources_read(&self, body: &str) -> Result<String, (i32, String)> {
2712 let params = json_rpc::extract_raw(body, "params")
2713 .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
2714 let uri = json_rpc::extract_str(¶ms, "uri")
2715 .ok_or((json_rpc::INVALID_PARAMS, "Missing uri".to_string()))?;
2716
2717 let handler = {
2718 let resources = self.resources.read().unwrap();
2719 resources.iter().find(|r| uri_matches(&r.uri_template, &uri)).map(|r| r.handler.clone())
2720 }.ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Resource not found: {uri}")))?;
2721
2722 match handler(&uri) {
2723 Ok(c) => {
2724 let text_esc = json_escape(&c.text);
2725 let uri_esc = json_escape(&uri);
2726 let field = if c.kind == "blob" { "blob" } else { "text" };
2727 Ok(format!(
2728 r#"{{"contents":[{{"uri":"{uri_esc}","mimeType":"{}","{field}":"{text_esc}"}}]}}"#,
2729 json_escape(c.mime()),
2730 ))
2731 }
2732 Err(e) => Err((json_rpc::INVALID_PARAMS, e)),
2733 }
2734 }
2735
2736 /// Handle `resources/subscribe`: record that `session_id` (this
2737 /// request's `Mcp-Session-Id`) wants `notifications/resources/updated`
2738 /// pushed whenever [`Self::notify_resource_updated`] is called for
2739 /// `params.uri`. Requires a session id — without one there's no way to
2740 /// later match this subscription to a specific `GET /mcp` SSE
2741 /// connection, so a client that never sent `Mcp-Session-Id` gets
2742 /// `INVALID_PARAMS` rather than a subscription that can never fire.
2743 fn do_resource_subscribe(&self, body: &str, session_id: Option<String>) -> Result<String, (i32, String)> {
2744 let params = json_rpc::extract_raw(body, "params")
2745 .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
2746 let uri = json_rpc::extract_str(¶ms, "uri")
2747 .ok_or((json_rpc::INVALID_PARAMS, "Missing uri".to_string()))?;
2748 let session_id = session_id.ok_or((
2749 json_rpc::INVALID_PARAMS,
2750 "resources/subscribe requires an Mcp-Session-Id header (call initialize first)".to_string(),
2751 ))?;
2752
2753 let mut subs = self.subscriptions.lock().unwrap();
2754 let subscribers = subs.entry(uri).or_default();
2755 if !subscribers.contains(&session_id) {
2756 subscribers.push(session_id);
2757 }
2758 Ok("{}".to_string())
2759 }
2760
2761 /// Handle `resources/unsubscribe`: the inverse of
2762 /// [`Self::do_resource_subscribe`]. Removing the last subscriber for a
2763 /// URI drops the URI's entry entirely rather than leaving an empty
2764 /// `Vec` behind.
2765 fn do_resource_unsubscribe(&self, body: &str, session_id: Option<String>) -> Result<String, (i32, String)> {
2766 let params = json_rpc::extract_raw(body, "params")
2767 .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
2768 let uri = json_rpc::extract_str(¶ms, "uri")
2769 .ok_or((json_rpc::INVALID_PARAMS, "Missing uri".to_string()))?;
2770 let session_id = session_id.ok_or((
2771 json_rpc::INVALID_PARAMS,
2772 "resources/unsubscribe requires an Mcp-Session-Id header".to_string(),
2773 ))?;
2774
2775 let mut subs = self.subscriptions.lock().unwrap();
2776 if let Some(subscribers) = subs.get_mut(&uri) {
2777 subscribers.retain(|s| s != &session_id);
2778 if subscribers.is_empty() {
2779 subs.remove(&uri);
2780 }
2781 }
2782 Ok("{}".to_string())
2783 }
2784
2785 fn do_prompts_list(&self, body: &str) -> Result<String, (i32, String)> {
2786 let items: Vec<String> = self.prompts.read().unwrap().iter().map(|p| {
2787 let arg_defs: Vec<String> = p.arguments.iter().map(|a| {
2788 format!(
2789 r#"{{"name":"{}","description":"{}","required":{}}}"#,
2790 json_escape(&a.name),
2791 json_escape(&a.description),
2792 a.required,
2793 )
2794 }).collect();
2795 format!(
2796 r#"{{"name":"{}","description":"{}","arguments":[{}]}}"#,
2797 json_escape(&p.name),
2798 json_escape(&p.description),
2799 arg_defs.join(","),
2800 )
2801 }).collect();
2802 let (page, next_cursor) = self.paginate(&items, body)?;
2803 Ok(format!(r#"{{"prompts":[{}]{}}}"#, page.join(","), next_cursor_json(&next_cursor)))
2804 }
2805
2806 /// Slice `items` (already-rendered JSON object strings for one
2807 /// `*/list` response) according to [`Self::page_size`] and this
2808 /// request's `params.cursor`, returning the page and — if more items
2809 /// remain — the opaque `nextCursor` to embed in the response.
2810 ///
2811 /// Without a configured `page_size`, always returns every item and no
2812 /// cursor, i.e. pagination is fully opt-in.
2813 fn paginate<'a>(&self, items: &'a [String], body: &str) -> Result<(&'a [String], Option<String>), (i32, String)> {
2814 let page_size = match self.page_size {
2815 Some(n) => n,
2816 None => return Ok((items, None)),
2817 };
2818
2819 let cursor = json_rpc::extract_raw(body, "params")
2820 .and_then(|p| json_rpc::extract_str(&p, "cursor"));
2821
2822 let offset = match cursor {
2823 Some(c) => decode_cursor(&c)
2824 .ok_or((json_rpc::INVALID_PARAMS, "Invalid cursor".to_string()))?,
2825 None => 0,
2826 };
2827
2828 if offset >= items.len() {
2829 return Ok((&[], None));
2830 }
2831
2832 let end = (offset + page_size).min(items.len());
2833 let next_cursor = if end < items.len() { Some(encode_cursor(end)) } else { None };
2834 Ok((&items[offset..end], next_cursor))
2835 }
2836
2837 fn do_prompts_get(&self, body: &str) -> Result<String, (i32, String)> {
2838 let params = json_rpc::extract_raw(body, "params")
2839 .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
2840 let name = json_rpc::extract_str(¶ms, "name")
2841 .ok_or((json_rpc::INVALID_PARAMS, "Missing prompt name".to_string()))?;
2842 let args = json_rpc::extract_raw(¶ms, "arguments")
2843 .unwrap_or_else(|| "{}".to_string());
2844
2845 let (description, handler) = {
2846 let prompts = self.prompts.read().unwrap();
2847 prompts.iter().find(|p| p.name == name).map(|p| (p.description.clone(), p.handler.clone()))
2848 }.ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Unknown prompt: {name}")))?;
2849
2850 match handler(&args) {
2851 Ok(msgs) => {
2852 let msg_jsons: Vec<String> = msgs.iter().map(|m| m.to_json()).collect();
2853 Ok(format!(
2854 r#"{{"description":"{}","messages":[{}]}}"#,
2855 json_escape(&description),
2856 msg_jsons.join(","),
2857 ))
2858 }
2859 Err(e) => Err((json_rpc::INVALID_PARAMS, e)),
2860 }
2861 }
2862
2863 /// Handle `completion/complete`: look up the registered
2864 /// [`CompletionDef`] matching `params.ref.type`/`params.ref.name`, call
2865 /// its handler with `params.argument.name`/`params.argument.value`, and
2866 /// render the spec's `{"completion":{"values":[...],"hasMore":...,
2867 /// "total":...}}` shape. No registered provider for the given ref/name
2868 /// (or an unrecognized `ref.type` not stripped of `"ref/"`) returns an
2869 /// empty `values` list rather than an error — matching the spec's own
2870 /// framing of completion as a best-effort hint, not a required capability
2871 /// per tool/prompt.
2872 fn do_completion(&self, body: &str) -> Result<String, (i32, String)> {
2873 let params = json_rpc::extract_raw(body, "params")
2874 .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
2875 let reference = json_rpc::extract_raw(¶ms, "ref")
2876 .ok_or((json_rpc::INVALID_PARAMS, "Missing ref".to_string()))?;
2877 let ref_type_raw = json_rpc::extract_str(&reference, "type")
2878 .ok_or((json_rpc::INVALID_PARAMS, "Missing ref.type".to_string()))?;
2879 let ref_type = ref_type_raw.strip_prefix("ref/").unwrap_or(&ref_type_raw);
2880 let ref_name = json_rpc::extract_str(&reference, "name")
2881 .ok_or((json_rpc::INVALID_PARAMS, "Missing ref.name".to_string()))?;
2882
2883 let argument = json_rpc::extract_raw(¶ms, "argument")
2884 .ok_or((json_rpc::INVALID_PARAMS, "Missing argument".to_string()))?;
2885 let arg_name = json_rpc::extract_str(&argument, "name")
2886 .ok_or((json_rpc::INVALID_PARAMS, "Missing argument.name".to_string()))?;
2887 let partial = json_rpc::extract_str(&argument, "value").unwrap_or_default();
2888
2889 let handler = {
2890 let completions = self.completions.read().unwrap();
2891 completions.iter()
2892 .find(|c| c.ref_type == ref_type && c.ref_name == ref_name)
2893 .map(|c| c.handler.clone())
2894 };
2895
2896 let values = match handler {
2897 Some(h) => h(&arg_name, &partial).map_err(|e| (json_rpc::INVALID_PARAMS, e))?,
2898 None => vec![],
2899 };
2900
2901 let total = values.len();
2902 let has_more = total > MAX_COMPLETION_VALUES;
2903 let page = if has_more { &values[..MAX_COMPLETION_VALUES] } else { &values[..] };
2904 let values_json: Vec<String> = page.iter().map(|v| format!(r#""{}""#, json_escape(v))).collect();
2905
2906 Ok(format!(
2907 r#"{{"completion":{{"values":[{}],"hasMore":{has_more},"total":{total}}}}}"#,
2908 values_json.join(","),
2909 ))
2910 }
2911
2912 /// Answers a `tools/call` as an SSE stream (Streamable HTTP): the
2913 /// handler runs on its own thread, its progress notifications go out as
2914 /// they happen, then the JSON-RPC response, and the stream ends.
2915 fn stream_tool_call(&self, body: &str, mut ctx: McpContext) -> Response {
2916 let (tx, rx) = mpsc::sync_channel::<Vec<u8>>(32);
2917 ctx.request_stream = Some(tx.clone());
2918 let server = self.clone();
2919 let body = body.to_string();
2920 std::thread::spawn(move || {
2921 let response = server.handle_request_with_context(&body, ctx);
2922 let json = response.content_range_list.first().map(|part| String::from_utf8_lossy(&part.body).into_owned()).unwrap_or_default();
2923 let _ = tx.send(format!("data: {json}\n\n").into_bytes());
2924 });
2925 let mut response = Response::new();
2926 response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
2927 response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
2928 response.headers.push(Header { name: Header::_CONTENT_TYPE.into(), value: "text/event-stream".into() });
2929 response.headers.push(Header { name: Header::_CACHE_CONTROL.into(), value: "no-cache".into() });
2930 response.headers.push(Header { name: "X-Accel-Buffering".into(), value: "no".into() });
2931 response.stream_pipe = Some(Box::new(SseChannelReader::new(rx)));
2932 response
2933 }
2934
2935 /// Build the [`McpContext`] for an incoming request: the `Mcp-Session-Id`
2936 /// header, if present, plus whatever `clientInfo` was recorded for that
2937 /// session at `initialize` time (if this session is recognized).
2938 fn context_for(&self, request: &Request) -> McpContext {
2939 let session_id = request
2940 .get_header("Mcp-Session-Id".to_string())
2941 .map(|h| h.value.to_string());
2942
2943 let (client_name, client_version, sampling_supported, roots_supported) = match &session_id {
2944 Some(sid) => match self.sessions.lock().unwrap().get(sid) {
2945 Some(info) => (info.name.clone(), info.version.clone(), info.supports_sampling, info.supports_roots),
2946 None => (None, None, false, false),
2947 },
2948 None => (None, None, false, false),
2949 };
2950
2951 McpContext {
2952 client_name,
2953 client_version,
2954 session_id,
2955 auth_claims: None,
2956 progress_token: None,
2957 sse_clients: Some(self.sse_clients.clone()),
2958 cancellation: None,
2959 sampling_supported,
2960 pending_replies: Some(self.pending_replies.clone()),
2961 roots_supported,
2962 sessions: Some(self.sessions.clone()),
2963 request_stream: None,
2964 }
2965 }
2966}
2967
2968/// Render one JSON-RPC 2.0 notification (no `id` — fire-and-forget, per
2969/// spec) as an SSE `data:`-ready message body. Shared by [`McpServer::notify`]
2970/// and [`McpContext::report_progress`].
2971/// A single `tools/call` POST that asks for progress (`_meta.progressToken`)
2972/// from a client that accepts an SSE response.
2973fn wants_progress_stream(request: &Request, body: &str) -> bool {
2974 let accepts_sse = request.headers.iter()
2975 .filter(|h| h.name.eq_ignore_ascii_case("accept"))
2976 .any(|h| h.value.to_ascii_lowercase().contains("text/event-stream"));
2977 if !accepts_sse || body.trim_start().starts_with('[') {
2978 return false;
2979 }
2980 let Some(members) = json_rpc::object_members(body) else { return false };
2981 let member = |key: &str| members.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str());
2982 member("method") == Some(r#""tools/call""#)
2983 && member("id").is_some()
2984 && member("params")
2985 .and_then(json_rpc::object_members)
2986 .and_then(|params| params.into_iter().find(|(k, _)| k == "_meta"))
2987 .and_then(|(_, meta)| json_rpc::object_members(&meta))
2988 .is_some_and(|meta| meta.iter().any(|(k, _)| k == "progressToken"))
2989}
2990
2991fn render_notification(method: &str, params_json: Option<&str>) -> String {
2992 let params_field = match params_json {
2993 Some(p) => format!(r#","params":{p}"#),
2994 None => String::new(),
2995 };
2996 format!(r#"{{"jsonrpc":"2.0","method":"{}"{}}}"#, json_escape(method), params_field)
2997}
2998
2999/// Send a raw pre-built JSON-RPC message to every client in `clients`,
3000/// pruning any whose channel is full or disconnected. Shared by
3001/// [`McpServer::notify`] and [`McpContext::report_progress`] — the latter
3002/// only has a clone of the broadcast list, not a whole `McpServer`.
3003fn broadcast_sse_to(clients: &Arc<Mutex<Vec<SseClient>>>, json: &str) {
3004 let frame = format!("data: {json}\n\n").into_bytes();
3005 let mut clients = clients.lock().unwrap();
3006 clients.retain(|c| c.sender.try_send(frame.clone()).is_ok());
3007}
3008
3009/// Send a raw pre-built JSON-RPC message only to clients whose `session_id`
3010/// is in `session_ids`, pruning any of *those* whose channel is full or
3011/// disconnected. A client with no `session_id`, or one not in
3012/// `session_ids`, is left untouched (kept, not sent to) — this is a
3013/// targeted send for [`McpServer::notify_resource_updated`], not a
3014/// broadcast like [`broadcast_sse_to`].
3015fn send_sse_to_sessions(clients: &Arc<Mutex<Vec<SseClient>>>, session_ids: &[String], json: &str) {
3016 let frame = format!("data: {json}\n\n").into_bytes();
3017 let mut clients = clients.lock().unwrap();
3018 clients.retain(|c| {
3019 let is_target = c.session_id.as_deref().is_some_and(|sid| session_ids.iter().any(|s| s == sid));
3020 if is_target {
3021 c.sender.try_send(frame.clone()).is_ok()
3022 } else {
3023 true
3024 }
3025 });
3026}
3027
3028// ── SSE channel reader ────────────────────────────────────────────────────────
3029
3030/// Adapts an `mpsc::Receiver<Vec<u8>>` of pre-framed SSE bytes into a
3031/// blocking [`std::io::Read`], so `Server::pipe_stream` (already written for
3032/// proxy passthrough streaming) can drive a `GET /mcp` SSE connection with no
3033/// changes to the server's write loop.
3034///
3035/// Blocks in [`Self::read`] until either a frame arrives, the sender side is
3036/// dropped (all `McpServer` clones gone — EOF, closing the connection), or
3037/// [`SSE_KEEPALIVE_INTERVAL`] elapses with nothing to send (writes a `:
3038/// keep-alive` comment instead, both to satisfy proxies that time out
3039/// silent connections and to surface a dead peer on the next write attempt).
3040struct SseChannelReader {
3041 rx: mpsc::Receiver<Vec<u8>>,
3042 leftover: Vec<u8>,
3043}
3044
3045impl SseChannelReader {
3046 fn new(rx: mpsc::Receiver<Vec<u8>>) -> Self {
3047 SseChannelReader { rx, leftover: Vec::new() }
3048 }
3049}
3050
3051impl std::io::Read for SseChannelReader {
3052 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
3053 if self.leftover.is_empty() {
3054 loop {
3055 match self.rx.recv_timeout(SSE_KEEPALIVE_INTERVAL) {
3056 Ok(frame) => { self.leftover = frame; break; }
3057 Err(mpsc::RecvTimeoutError::Timeout) => {
3058 self.leftover = b": keep-alive\n\n".to_vec();
3059 break;
3060 }
3061 Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(0),
3062 }
3063 }
3064 }
3065
3066 let n = self.leftover.len().min(buf.len());
3067 buf[..n].copy_from_slice(&self.leftover[..n]);
3068 self.leftover.drain(..n);
3069 Ok(n)
3070 }
3071}
3072
3073// ── Application ───────────────────────────────────────────────────────────────
3074
3075impl Application for McpServer {
3076 fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
3077 // RFC 9728 protected-resource metadata (MCP 2025-06-18), at the
3078 // well-known path and at it suffixed with the endpoint's path.
3079 #[cfg(feature = "sso")]
3080 if request.method == "GET" {
3081 if let Some(oauth) = &self.oauth {
3082 let target = request.request_uri.split('?').next().unwrap_or("");
3083 let suffixed = format!("{}{}", PROTECTED_RESOURCE_PATH, self.path);
3084 if target == PROTECTED_RESOURCE_PATH || target == suffixed {
3085 return Ok(protected_resource_document(oauth, request, &self.path));
3086 }
3087 }
3088 }
3089
3090 if request.request_uri == self.path {
3091 // Browser requests (they carry `Origin`) only from listed origins.
3092 if let Some(origin) = request.headers.iter().find(|h| h.name.eq_ignore_ascii_case("origin")) {
3093 let origin = origin.value.trim();
3094 if !self.allowed_origins.iter().any(|o| o.trim_end_matches('/').eq_ignore_ascii_case(origin.trim_end_matches('/'))) {
3095 return Ok(forbidden_origin());
3096 }
3097 }
3098 // JSON-RPC bodies only: a form or text/plain POST is what a web
3099 // page can send cross-site without a CORS preflight.
3100 if request.method == "POST" {
3101 let content_type = request.headers.iter()
3102 .find(|h| h.name.eq_ignore_ascii_case("content-type"))
3103 .map(|h| h.value.as_ref())
3104 .unwrap_or("");
3105 let media_type = content_type.split(';').next().unwrap_or("").trim();
3106 if !media_type.eq_ignore_ascii_case("application/json") {
3107 return Ok(unsupported_media_type());
3108 }
3109 }
3110
3111 #[cfg_attr(not(feature = "sso"), allow(unused_mut))]
3112 let mut auth_claims_json: Option<String> = None;
3113 #[cfg(feature = "sso")]
3114 let oauth_configured = self.oauth.is_some();
3115 #[cfg(not(feature = "sso"))]
3116 let oauth_configured = false;
3117
3118 // OAuth (sso feature) takes precedence over bearer tokens if both
3119 // are somehow configured — see require_oauth's doc comment.
3120 #[cfg(feature = "sso")]
3121 if let Some(oauth) = &self.oauth {
3122 let provided = request.headers.iter()
3123 .find(|h| h.name.eq_ignore_ascii_case("authorization"))
3124 .map(|h| h.value.as_ref())
3125 .unwrap_or("");
3126 let bearer = provided.strip_prefix("Bearer ").unwrap_or("");
3127 if bearer.is_empty() {
3128 return Ok(unauthorized_with_metadata(oauth, request, &self.path));
3129 }
3130 let opts = crate::sso::jwks::VerifyOptions {
3131 audience: &oauth.audience,
3132 issuer: &oauth.provider.issuer,
3133 leeway_secs: 60,
3134 };
3135 match oauth.jwks.verify_jwt(bearer, &opts) {
3136 Ok(claims) => auth_claims_json = serde_json::to_string(&claims).ok(),
3137 Err(_) => return Ok(unauthorized_with_metadata(oauth, request, &self.path)),
3138 }
3139 }
3140 if !oauth_configured {
3141 match self.check_bearer(request) {
3142 Ok(claims) => auth_claims_json = claims,
3143 Err(response) => return Ok(response),
3144 }
3145 }
3146
3147 // A session id this server doesn't know — never minted, ended,
3148 // or evicted — is 404: the client must initialize again
3149 // (Streamable HTTP transport). An `initialize` POST may carry a
3150 // stale one.
3151 let session_id = request.headers.iter()
3152 .find(|h| h.name.eq_ignore_ascii_case("mcp-session-id"))
3153 .map(|h| h.value.to_string());
3154 if let Some(sid) = &session_id {
3155 let initializing = request.method == "POST"
3156 && json_rpc::extract_str(std::str::from_utf8(&request.body).unwrap_or(""), "method").as_deref() == Some("initialize");
3157 if request.method != "OPTIONS" && !initializing && !self.touch_session(sid) {
3158 return Ok(session_not_found());
3159 }
3160 }
3161 // The protocol version in effect: the header (2025-06-18+
3162 // clients send it on every request after initialize), else the
3163 // session's, else 2025-03-26. A version this server doesn't speak
3164 // is 400 (Streamable HTTP transport).
3165 let header_version = request.headers.iter()
3166 .find(|h| h.name.eq_ignore_ascii_case("mcp-protocol-version"))
3167 .map(|h| h.value.trim().to_string());
3168 if let Some(v) = &header_version {
3169 if !SUPPORTED_PROTOCOL_VERSIONS.contains(&v.as_str()) {
3170 return Ok(bad_protocol_version(v));
3171 }
3172 }
3173 if request.method == "POST" && request.body.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'[') {
3174 let version = header_version.clone()
3175 .or_else(|| session_id.as_ref().and_then(|sid| self.sessions.lock().unwrap().get(sid).map(|s| s.protocol_version.to_string())))
3176 .unwrap_or_else(|| ASSUMED_PROTOCOL_VERSION.to_string());
3177 // JSON-RPC batching was removed in 2025-06-18.
3178 if version.as_str() >= "2025-06-18" {
3179 return Ok(rpc_error(None, json_rpc::INVALID_REQUEST,
3180 &format!("JSON-RPC batches are not supported in protocol version {version}")));
3181 }
3182 }
3183 return Ok(match request.method.as_str() {
3184 "DELETE" => match session_id {
3185 // The client ends its session.
3186 Some(sid) => {
3187 self.sessions.lock().unwrap().remove(&sid);
3188 self.forget_sessions(&[sid]);
3189 let mut r = Response::new();
3190 r.status_code = *STATUS_CODE_REASON_PHRASE.n204_no_content.status_code;
3191 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n204_no_content.reason_phrase.to_string();
3192 r
3193 }
3194 None => {
3195 let mut r = Response::new();
3196 r.status_code = *STATUS_CODE_REASON_PHRASE.n400_bad_request.status_code;
3197 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n400_bad_request.reason_phrase.to_string();
3198 r.content_range_list = vec![Range::get_content_range(
3199 b"DELETE /mcp needs an Mcp-Session-Id".to_vec(),
3200 MimeType::TEXT_PLAIN.to_string(),
3201 )];
3202 r
3203 }
3204 },
3205 "POST" => {
3206 let body = std::str::from_utf8(&request.body).unwrap_or("");
3207 let mut ctx = self.context_for(request);
3208 ctx.auth_claims = auth_claims_json;
3209 if wants_progress_stream(request, body) {
3210 self.stream_tool_call(body, ctx)
3211 } else {
3212 self.handle_request_with_context(body, ctx)
3213 }
3214 }
3215 "GET" => self.start_sse_stream(request),
3216 "OPTIONS" => {
3217 // CORS preflight for browser-based MCP clients
3218 let mut r = Response::new();
3219 r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
3220 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
3221 r.headers.push(Header {
3222 name: "Allow".into(),
3223 value: "GET, POST, DELETE, OPTIONS".into(),
3224 });
3225 r
3226 }
3227 _ => {
3228 let mut r = Response::new();
3229 r.status_code = *STATUS_CODE_REASON_PHRASE.n405_method_not_allowed.status_code;
3230 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n405_method_not_allowed.reason_phrase.to_string();
3231 r.headers.push(Header {
3232 name: "Allow".into(),
3233 value: "GET, POST, DELETE, OPTIONS".into(),
3234 });
3235 r.content_range_list = vec![Range::get_content_range(
3236 b"MCP endpoint only accepts GET (SSE), POST or DELETE".to_vec(),
3237 MimeType::TEXT_PLAIN.to_string(),
3238 )];
3239 r
3240 }
3241 });
3242 }
3243
3244 // Not an MCP path — fall through to the wrapped app (or built-in App).
3245 match &self.fallback {
3246 Some(app) => app.execute(request, connection),
3247 None => App::new().execute(request, connection),
3248 }
3249 }
3250
3251 /// MCP calls run tool handlers (user code): never inline. Other requests
3252 /// go to the fallback app, which decides.
3253 fn runs_inline(&self, request: &Request, connection: &ConnectionInfo) -> bool {
3254 if request.request_uri == self.path || request.request_uri.starts_with("/.well-known/") {
3255 return false;
3256 }
3257 match &self.fallback {
3258 Some(app) => app.runs_inline(request, connection),
3259 None => App::is_inline_request(request),
3260 }
3261 }
3262
3263 /// MCP requests are JSON-RPC read whole; other paths: the wrapped app.
3264 fn streams_body(&self, request: &Request, connection: &ConnectionInfo) -> bool {
3265 if request.request_uri == self.path || request.request_uri.starts_with("/.well-known/") {
3266 return false;
3267 }
3268 self.fallback.as_ref().is_some_and(|app| app.streams_body(request, connection))
3269 }
3270
3271 #[cfg(feature = "http2")]
3272 fn execute_async<'a>(&'a self, request: &'a Request, connection: &'a ConnectionInfo) -> Option<crate::application::AsyncResponse<'a>> {
3273 if request.request_uri == self.path || request.request_uri.starts_with("/.well-known/") {
3274 return None;
3275 }
3276 self.fallback.as_ref()?.execute_async(request, connection)
3277 }
3278}
3279
3280// ── public helper ─────────────────────────────────────────────────────────────
3281
3282/// Extract an argument from a tool/prompt `arguments` JSON object: a
3283/// string's value, or a number's or boolean's literal text (`"3"`,
3284/// `"true"`). Objects, arrays and `null` are `None`. Numbers and booleans
3285/// used to be `None` too, so a tool reading `"enabled": true` saw nothing.
3286///
3287/// ```rust
3288/// use rust_web_server::mcp::extract_arg;
3289/// assert_eq!(extract_arg(r#"{"text":"hello"}"#, "text").as_deref(), Some("hello"));
3290/// assert_eq!(extract_arg(r#"{"enabled":true,"n":3}"#, "enabled").as_deref(), Some("true"));
3291/// assert_eq!(extract_arg(r#"{"enabled":true,"n":3}"#, "n").as_deref(), Some("3"));
3292/// assert_eq!(extract_arg(r#"{}"#, "missing"), None);
3293/// ```
3294pub fn extract_arg(arguments: &str, name: &str) -> Option<String> {
3295 if let Some(members) = json_rpc::object_members(arguments) {
3296 let (_, raw) = members.into_iter().find(|(key, _)| key == name)?;
3297 return match raw.as_bytes().first() {
3298 Some(b'"') => json_rpc::extract_str(&format!(r#"{{"v":{raw}}}"#), "v"),
3299 Some(b'{' | b'[') | None => None,
3300 _ if raw == "null" => None,
3301 _ => Some(raw),
3302 };
3303 }
3304 json_rpc::extract_str(arguments, name)
3305}
3306
3307// ── internal helpers ──────────────────────────────────────────────────────────
3308
3309fn json_response(body: &str) -> Response {
3310 let mut r = Response::new();
3311 r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
3312 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
3313 r.content_range_list = vec![Range::get_content_range(
3314 body.as_bytes().to_vec(),
3315 MimeType::APPLICATION_JSON.to_string(),
3316 )];
3317 r
3318}
3319
3320fn no_content() -> Response {
3321 let mut r = Response::new();
3322 r.status_code = *STATUS_CODE_REASON_PHRASE.n202_accepted.status_code;
3323 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n202_accepted.reason_phrase.to_string();
3324 r
3325}
3326
3327/// A configured static token against the presented one: never matches an
3328/// empty token (fails closed), compared in constant time.
3329fn bearer_matches(expected: &str, provided: &str) -> bool {
3330 !expected.is_empty() && crate::proxy_config::constant_time_eq(expected.as_bytes(), provided.as_bytes())
3331}
3332
3333/// `403` for a browser `Origin` not in [`McpServer::allowed_origins`].
3334fn forbidden_origin() -> Response {
3335 let mut r = Response::new();
3336 r.status_code = *STATUS_CODE_REASON_PHRASE.n403_forbidden.status_code;
3337 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n403_forbidden.reason_phrase.to_string();
3338 r.content_range_list = vec![Range::get_content_range(
3339 b"Origin not allowed".to_vec(),
3340 MimeType::TEXT_PLAIN.to_string(),
3341 )];
3342 r
3343}
3344
3345/// `415` for a POST whose body isn't `application/json`.
3346fn unsupported_media_type() -> Response {
3347 let mut r = Response::new();
3348 r.status_code = *STATUS_CODE_REASON_PHRASE.n415_unsupported_media_type.status_code;
3349 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n415_unsupported_media_type.reason_phrase.to_string();
3350 r.content_range_list = vec![Range::get_content_range(
3351 b"MCP requests must be application/json".to_vec(),
3352 MimeType::TEXT_PLAIN.to_string(),
3353 )];
3354 r
3355}
3356
3357/// `400` for an `MCP-Protocol-Version` this server doesn't speak.
3358fn bad_protocol_version(version: &str) -> Response {
3359 let mut r = Response::new();
3360 r.status_code = *STATUS_CODE_REASON_PHRASE.n400_bad_request.status_code;
3361 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n400_bad_request.reason_phrase.to_string();
3362 r.content_range_list = vec![Range::get_content_range(
3363 format!("Unsupported MCP-Protocol-Version {:?}; supported: {}", version, SUPPORTED_PROTOCOL_VERSIONS.join(", ")).into_bytes(),
3364 MimeType::TEXT_PLAIN.to_string(),
3365 )];
3366 r
3367}
3368
3369/// `404` for a request in a session this server doesn't know (anymore).
3370fn session_not_found() -> Response {
3371 let mut r = Response::new();
3372 r.status_code = *STATUS_CODE_REASON_PHRASE.n404_not_found.status_code;
3373 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string();
3374 r.content_range_list = vec![Range::get_content_range(
3375 br#"{"jsonrpc":"2.0","error":{"code":-32001,"message":"Session not found: initialize a new one"},"id":null}"#.to_vec(),
3376 MimeType::APPLICATION_JSON.to_string(),
3377 )];
3378 r
3379}
3380
3381fn unauthorized() -> Response {
3382 let mut r = Response::new();
3383 r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
3384 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
3385 r.headers.push(Header {
3386 name: "WWW-Authenticate".into(),
3387 value: "Bearer".into(),
3388 });
3389 r.content_range_list = vec![Range::get_content_range(
3390 b"Unauthorized".to_vec(),
3391 MimeType::TEXT_PLAIN.to_string(),
3392 )];
3393 r
3394}
3395
3396/// RFC 9728's well-known path for protected-resource metadata.
3397#[cfg(feature = "sso")]
3398const PROTECTED_RESOURCE_PATH: &str = "/.well-known/oauth-protected-resource";
3399
3400/// The protected resource's identifier: the configured audience when it is
3401/// an absolute URL (RFC 8707 — the value tokens are minted for), else this
3402/// server's origin from the request plus the MCP path.
3403#[cfg(feature = "sso")]
3404fn resource_identifier(oauth: &OAuthConfig, request: &Request, path: &str) -> String {
3405 if oauth.audience.starts_with("https://") || oauth.audience.starts_with("http://") {
3406 return oauth.audience.clone();
3407 }
3408 format!("{}{}", request_origin(request), path)
3409}
3410
3411/// `scheme://host` this request was sent to (`Host`; `https` unless the
3412/// host is a loopback name).
3413#[cfg(feature = "sso")]
3414fn request_origin(request: &Request) -> String {
3415 let host = request.headers.iter()
3416 .find(|h| h.name.eq_ignore_ascii_case("host"))
3417 .map(|h| h.value.to_string())
3418 .unwrap_or_else(|| "localhost".to_string());
3419 let local = host.starts_with("localhost") || host.starts_with("127.") || host.starts_with("[::1]");
3420 format!("{}://{}", if local { "http" } else { "https" }, host)
3421}
3422
3423/// The metadata document's URL, for `WWW-Authenticate`.
3424#[cfg(feature = "sso")]
3425fn metadata_url(oauth: &OAuthConfig, request: &Request, path: &str) -> String {
3426 let resource = resource_identifier(oauth, request, path);
3427 let origin = match resource.find("://").and_then(|i| resource[i + 3..].find('/').map(|j| i + 3 + j)) {
3428 Some(end) => resource[..end].to_string(),
3429 None => resource,
3430 };
3431 format!("{origin}{PROTECTED_RESOURCE_PATH}")
3432}
3433
3434/// `GET /.well-known/oauth-protected-resource` (RFC 9728): which
3435/// authorization server issues tokens for this MCP server, and the resource
3436/// identifier a client asks it for (RFC 8707). Served whenever
3437/// [`McpServer::require_oauth`] is configured; it replaced the
3438/// authorization-server-style document earlier versions served.
3439#[cfg(feature = "sso")]
3440fn protected_resource_document(oauth: &OAuthConfig, request: &Request, path: &str) -> Response {
3441 let body = format!(
3442 r#"{{"resource":"{}","authorization_servers":["{}"],"bearer_methods_supported":["header"]}}"#,
3443 json_escape(&resource_identifier(oauth, request, path)),
3444 json_escape(&oauth.provider.issuer),
3445 );
3446 let mut r = Response::new();
3447 r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
3448 r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
3449 r.content_range_list = vec![Range::get_content_range(
3450 body.into_bytes(),
3451 MimeType::APPLICATION_JSON.to_string(),
3452 )];
3453 r
3454}
3455
3456/// `401` whose `WWW-Authenticate` points at the protected-resource
3457/// metadata, so a client can discover the authorization server.
3458#[cfg(feature = "sso")]
3459fn unauthorized_with_metadata(oauth: &OAuthConfig, request: &Request, path: &str) -> Response {
3460 let mut r = unauthorized();
3461 if let Some(header) = r.headers.iter_mut().find(|h| h.name.eq_ignore_ascii_case("www-authenticate")) {
3462 header.value = format!(r#"Bearer resource_metadata="{}""#, metadata_url(oauth, request, path)).into();
3463 }
3464 r
3465}
3466
3467fn rpc_error(id: Option<&str>, code: i32, message: &str) -> Response {
3468 let id_str = id.unwrap_or("null");
3469 let escaped = json_escape(message);
3470 json_response(&format!(
3471 r#"{{"jsonrpc":"2.0","error":{{"code":{code},"message":"{escaped}"}},"id":{id_str}}}"#
3472 ))
3473}
3474
3475/// The JSON Schema type of a raw JSON value (`integer` for whole numbers).
3476fn json_type(raw: &str) -> &'static str {
3477 let raw = raw.trim();
3478 match raw.as_bytes().first() {
3479 Some(b'"') => "string",
3480 Some(b'{') => "object",
3481 Some(b'[') => "array",
3482 Some(b't' | b'f') => "boolean",
3483 Some(b'n') => "null",
3484 _ if raw.bytes().all(|b| b.is_ascii_digit() || b == b'-') => "integer",
3485 _ => "number",
3486 }
3487}
3488
3489pub(crate) fn json_escape(s: &str) -> String {
3490 let mut out = String::with_capacity(s.len() + 4);
3491 for ch in s.chars() {
3492 match ch {
3493 '"' => out.push_str("\\\""),
3494 '\\' => out.push_str("\\\\"),
3495 '\n' => out.push_str("\\n"),
3496 '\r' => out.push_str("\\r"),
3497 '\t' => out.push_str("\\t"),
3498 c if (c as u32) < 0x20 => { let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{:04x}", c as u32)); }
3499 c => out.push(c),
3500 }
3501 }
3502 out
3503}
3504
3505// ── pagination cursors ─────────────────────────────────────────────────────────
3506
3507/// Render `,"nextCursor":"..."` for a `*/list` response, or `""` if there's
3508/// no next page — spliced directly after the closing `]` of the items array.
3509fn next_cursor_json(next_cursor: &Option<String>) -> String {
3510 match next_cursor {
3511 Some(c) => format!(r#","nextCursor":"{}""#, json_escape(c)),
3512 None => String::new(),
3513 }
3514}
3515
3516/// Encode a `tools/list`/`resources/list`/`prompts/list` offset as the
3517/// opaque `nextCursor`/`params.cursor` string the MCP spec expects — just
3518/// base64 of the decimal offset, e.g. `50` → `"NTA="`. Callers only ever
3519/// treat this as opaque; the encoding is a private implementation detail of
3520/// this module, not a client-facing contract.
3521fn encode_cursor(offset: usize) -> String {
3522 base64_encode(offset.to_string().as_bytes())
3523}
3524
3525/// Decode a cursor produced by [`encode_cursor`]. Returns `None` for
3526/// anything that isn't valid base64 of a decimal `usize` — a malformed or
3527/// tampered cursor, not a crash.
3528fn decode_cursor(cursor: &str) -> Option<usize> {
3529 String::from_utf8(base64_decode(cursor)?).ok()?.parse().ok()
3530}
3531
3532const BASE64_TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3533
3534fn base64_encode(data: &[u8]) -> String {
3535 let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
3536 for chunk in data.chunks(3) {
3537 let b0 = chunk[0] as u32;
3538 let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
3539 let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
3540 let n = (b0 << 16) | (b1 << 8) | b2;
3541 out.push(BASE64_TABLE[((n >> 18) & 0x3F) as usize] as char);
3542 out.push(BASE64_TABLE[((n >> 12) & 0x3F) as usize] as char);
3543 out.push(if chunk.len() > 1 { BASE64_TABLE[((n >> 6) & 0x3F) as usize] as char } else { '=' });
3544 out.push(if chunk.len() > 2 { BASE64_TABLE[(n & 0x3F) as usize] as char } else { '=' });
3545 }
3546 out
3547}
3548
3549fn base64_decode(s: &str) -> Option<Vec<u8>> {
3550 fn sextet(c: u8) -> Option<u32> {
3551 match c {
3552 b'A'..=b'Z' => Some((c - b'A') as u32),
3553 b'a'..=b'z' => Some((c - b'a' + 26) as u32),
3554 b'0'..=b'9' => Some((c - b'0' + 52) as u32),
3555 b'+' => Some(62),
3556 b'/' => Some(63),
3557 _ => None,
3558 }
3559 }
3560
3561 let trimmed = s.trim_end_matches('=');
3562 let bytes = trimmed.as_bytes();
3563 let mut out = Vec::with_capacity(bytes.len() * 3 / 4 + 3);
3564 for chunk in bytes.chunks(4) {
3565 if chunk.len() == 1 {
3566 return None; // not a valid base64 length
3567 }
3568 let vals: Vec<u32> = chunk.iter().map(|&b| sextet(b)).collect::<Option<Vec<_>>>()?;
3569 let n = vals.iter().enumerate().fold(0u32, |acc, (i, &v)| acc | (v << (18 - 6 * i)));
3570 out.push(((n >> 16) & 0xFF) as u8);
3571 if vals.len() > 2 { out.push(((n >> 8) & 0xFF) as u8); }
3572 if vals.len() > 3 { out.push((n & 0xFF) as u8); }
3573 }
3574 Some(out)
3575}
3576
3577fn uri_matches(template: &str, uri: &str) -> bool {
3578 // Template `"user://{id}"` matches any URI starting with `"user://"`.
3579 match template.find('{') {
3580 Some(pos) => uri.starts_with(&template[..pos]),
3581 None => template == uri,
3582 }
3583}