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