Skip to main content

rust_web_server/mcp/
mod.rs

1//! Model Context Protocol (MCP) server — HTTP Streamable HTTP transport.
2//!
3//! [`McpServer`] implements [`Application`] so it can be passed directly to
4//! [`Server::run`]. Unmatched requests fall through to the built-in [`App`]
5//! controller chain (static files, health probes, etc.).
6//!
7//! # Quick start
8//!
9//! ```rust,no_run
10//! use rust_web_server::server::Server;
11//! use rust_web_server::mcp::{McpServer, McpContent, PromptMessage};
12//! # fn main() {
13//! let mcp = McpServer::new("my-server", "1.0")
14//!     // A tool: callable by the AI, like a function
15//!     .tool(
16//!         "echo",
17//!         "Echo text back",
18//!         r#"{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}"#,
19//!         |args| {
20//!             let text = rust_web_server::mcp::extract_arg(args, "text")
21//!                 .unwrap_or_else(|| "(nothing)".to_string());
22//!             Ok(McpContent::text(text))
23//!         },
24//!     )
25//!     // A resource: data the AI can read by URI
26//!     .resource(
27//!         "docs://{topic}",
28//!         "Documentation",
29//!         "Return documentation for a topic",
30//!         |uri| Ok(McpContent::text(format!("Documentation for: {uri}"))),
31//!     )
32//!     // A prompt template: reusable message structures
33//!     .prompt(
34//!         "summarize",
35//!         "Summarize the given text",
36//!         |args| {
37//!             let text = rust_web_server::mcp::extract_arg(args, "text")
38//!                 .unwrap_or_else(|| "some text".to_string());
39//!             Ok(vec![PromptMessage::user(format!("Please summarize: {text}"))])
40//!         },
41//!     );
42//!
43//! // let (listener, pool) = Server::setup().unwrap();
44//! // Server::run(listener, pool, mcp);
45//! # }
46//! ```
47//!
48//! # MCP endpoint
49//!
50//! All JSON-RPC messages are sent as `POST /mcp` (override with [`.at()`](McpServer::at)).
51//! The server implements the [MCP 2024-11-05 specification](https://spec.modelcontextprotocol.io).
52//!
53//! # Environment variables
54//!
55//! None — configure the server programmatically via the builder.
56
57mod json_rpc;
58
59#[cfg(test)]
60mod tests;
61
62use std::collections::HashMap;
63use std::sync::{Arc, Mutex};
64
65use crate::app::App;
66use crate::application::Application;
67use crate::core::New;
68use crate::header::Header;
69use crate::mime_type::MimeType;
70use crate::range::Range;
71use crate::request::Request;
72use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
73use crate::server::ConnectionInfo;
74
75const PROTOCOL_VERSION: &str = "2024-11-05";
76
77// ── public content types ──────────────────────────────────────────────────────
78
79/// Content returned by tool and resource handlers.
80///
81/// Create with [`McpContent::text`] (plain text or JSON strings) or
82/// [`McpContent::json`] (marks MIME type as `application/json`).
83#[derive(Clone, Debug)]
84pub struct McpContent {
85    /// Always `"text"` in the current MCP spec.
86    pub kind: &'static str,
87    /// The content string.
88    pub text: String,
89    /// Optional MIME type override (default `"text/plain"`).
90    pub mime_type: Option<String>,
91}
92
93impl McpContent {
94    /// Plain-text content.
95    pub fn text(s: impl Into<String>) -> Self {
96        McpContent { kind: "text", text: s.into(), mime_type: None }
97    }
98
99    /// JSON content — sets `mimeType` to `application/json`.
100    pub fn json(s: impl Into<String>) -> Self {
101        McpContent { kind: "text", text: s.into(), mime_type: Some("application/json".to_string()) }
102    }
103
104    fn to_content_json(&self) -> String {
105        let escaped = json_escape(&self.text);
106        format!(r#"{{"type":"{}","text":"{}"}}"#, self.kind, escaped)
107    }
108
109    fn mime(&self) -> &str {
110        self.mime_type.as_deref().unwrap_or("text/plain")
111    }
112}
113
114/// A single message in a prompt response.
115#[derive(Clone, Debug)]
116pub struct PromptMessage {
117    /// `"user"` or `"assistant"`.
118    pub role: &'static str,
119    /// The message content.
120    pub content: McpContent,
121}
122
123impl PromptMessage {
124    /// Build a user-role message.
125    pub fn user(text: impl Into<String>) -> Self {
126        PromptMessage { role: "user", content: McpContent::text(text) }
127    }
128
129    /// Build an assistant-role message.
130    pub fn assistant(text: impl Into<String>) -> Self {
131        PromptMessage { role: "assistant", content: McpContent::text(text) }
132    }
133
134    fn to_json(&self) -> String {
135        format!(
136            r#"{{"role":"{}","content":{}}}"#,
137            self.role,
138            self.content.to_content_json(),
139        )
140    }
141}
142
143/// Argument definition for a prompt template.
144#[derive(Clone)]
145pub struct PromptArgDef {
146    pub name: String,
147    pub description: String,
148    pub required: bool,
149}
150
151impl PromptArgDef {
152    pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
153        PromptArgDef { name: name.into(), description: description.into(), required: true }
154    }
155
156    pub fn optional(name: impl Into<String>, description: impl Into<String>) -> Self {
157        PromptArgDef { name: name.into(), description: description.into(), required: false }
158    }
159}
160
161// ── McpContext ────────────────────────────────────────────────────────────────
162
163/// Per-request context passed to tool handlers registered via
164/// [`McpServer::tool_with_context`] — caller identity and session info that a
165/// plain `Fn(&str) -> ...` tool handler has no way to see.
166///
167/// Constructed in [`McpServer::execute`] from the current request's headers
168/// plus whatever `clientInfo` was recorded for this session at `initialize`
169/// time (see [`McpServer::handle_request_with_context`]).
170#[derive(Debug, Clone, Default)]
171pub struct McpContext {
172    /// `clientInfo.name` sent in this session's `initialize` call, if the
173    /// client sent one and this request carries a recognized `Mcp-Session-Id`.
174    pub client_name: Option<String>,
175    /// `clientInfo.version` sent in this session's `initialize` call, under
176    /// the same conditions as `client_name`.
177    pub client_version: Option<String>,
178    /// The `Mcp-Session-Id` header on this request, if present — the value
179    /// the server minted and returned in the `initialize` response header
180    /// for this session (see the module docs' Sessions section).
181    pub session_id: Option<String>,
182    /// Verified JWT claims as a JSON string. Not populated by anything in
183    /// this crate yet — reserved for a future JWT-auth integration
184    /// (MCP_TODO.md TODO-11/TODO-13); always `None` today.
185    pub auth_claims: Option<String>,
186}
187
188/// `clientInfo` recorded for one session at `initialize` time, looked up by
189/// `Mcp-Session-Id` for later requests in the same session. See
190/// `McpServer`'s `sessions` field doc comment for the unbounded-growth caveat.
191#[derive(Clone, Default)]
192struct StoredClientInfo {
193    name: Option<String>,
194    version: Option<String>,
195}
196
197// ── ToolAnnotations ───────────────────────────────────────────────────────────
198
199/// Behavioral hints for a tool, per the MCP 2025-03-26 spec's tool
200/// annotations. Clients (Claude Desktop and others) use these to decide
201/// whether to warn or ask for confirmation before calling a tool — e.g. skip
202/// confirmation for a read-only tool, or warn before a destructive one.
203///
204/// Every field is a *hint*, not something this server enforces or verifies —
205/// nothing stops a handler registered with `read_only_hint: Some(true)` from
206/// actually writing to disk. A well-behaved server sets these accurately;
207/// a client is free to ignore them or ask for confirmation anyway.
208///
209/// Register with [`McpServer::tool_annotated`]. Build one with plain struct
210/// syntax — every field defaults to `None` (no hint given, the client's own
211/// default applies):
212///
213/// ```rust
214/// use rust_web_server::mcp::ToolAnnotations;
215///
216/// let destructive = ToolAnnotations {
217///     destructive_hint: Some(true),
218///     read_only_hint: Some(false),
219///     ..Default::default()
220/// };
221/// ```
222#[derive(Debug, Clone, Copy, Default)]
223pub struct ToolAnnotations {
224    /// The tool does not modify its environment.
225    pub read_only_hint: Option<bool>,
226    /// The tool may perform destructive updates (only meaningful when
227    /// `read_only_hint` is not `Some(true)`).
228    pub destructive_hint: Option<bool>,
229    /// Calling the tool repeatedly with the same arguments has no additional
230    /// effect beyond the first call.
231    pub idempotent_hint: Option<bool>,
232    /// The tool may interact with an open-ended set of external entities
233    /// (e.g. web search), as opposed to a fixed, closed set.
234    pub open_world_hint: Option<bool>,
235}
236
237impl ToolAnnotations {
238    /// Render as a JSON object containing only the hints that are `Some`,
239    /// using the spec's camelCase key names. Returns `"{}"` if every field
240    /// is `None`.
241    fn to_json(self) -> String {
242        let mut fields = Vec::with_capacity(4);
243        if let Some(v) = self.read_only_hint {
244            fields.push(format!(r#""readOnlyHint":{v}"#));
245        }
246        if let Some(v) = self.destructive_hint {
247            fields.push(format!(r#""destructiveHint":{v}"#));
248        }
249        if let Some(v) = self.idempotent_hint {
250            fields.push(format!(r#""idempotentHint":{v}"#));
251        }
252        if let Some(v) = self.open_world_hint {
253            fields.push(format!(r#""openWorldHint":{v}"#));
254        }
255        format!("{{{}}}", fields.join(","))
256    }
257}
258
259// ── internal handler registrations ───────────────────────────────────────────
260
261type ToolFn     = Arc<dyn Fn(McpContext, &str) -> Result<McpContent, String>    + Send + Sync>;
262type ResourceFn = Arc<dyn Fn(&str) -> Result<McpContent, String>    + Send + Sync>;
263type PromptFn   = Arc<dyn Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync>;
264
265#[derive(Clone)]
266struct ToolDef {
267    name: String,
268    description: String,
269    input_schema: String,
270    annotations: Option<ToolAnnotations>,
271    handler: ToolFn,
272}
273
274#[derive(Clone)]
275struct ResourceDef {
276    uri_template: String,
277    name: String,
278    description: String,
279    handler: ResourceFn,
280}
281
282#[derive(Clone)]
283struct PromptDef {
284    name: String,
285    description: String,
286    arguments: Vec<PromptArgDef>,
287    handler: PromptFn,
288}
289
290// ── McpServer ─────────────────────────────────────────────────────────────────
291
292/// An HTTP server that implements the MCP 2024-11-05 protocol.
293///
294/// Register tools, resources, and prompts with the builder methods, then pass
295/// the server to [`Server::run`] (or [`Server::run_tls`]) as an [`Application`].
296/// Requests that do not match the MCP endpoint fall through to the built-in
297/// [`App`] controller chain.
298#[derive(Clone)]
299pub struct McpServer {
300    server_name: String,
301    server_version: String,
302    path: String,
303    tools: Vec<ToolDef>,
304    resources: Vec<ResourceDef>,
305    prompts: Vec<PromptDef>,
306    fallback: Option<Arc<dyn Application + Send + Sync>>,
307    auth_token: Option<String>,
308    /// `clientInfo` recorded per `Mcp-Session-Id`, minted at `initialize` time.
309    /// `Arc<Mutex<_>>` so every clone of this `McpServer` shares one map.
310    ///
311    /// This map only grows — nothing ever removes an entry, since there's no
312    /// session-termination signal in the MCP Streamable HTTP transport to key
313    /// eviction off of. Fine for the expected usage (a modest, roughly-stable
314    /// set of long-lived AI-agent clients); a public-internet-facing server
315    /// churning through unbounded distinct clients would leak memory here
316    /// with no built-in reaping mechanism.
317    sessions: Arc<Mutex<HashMap<String, StoredClientInfo>>>,
318}
319
320impl McpServer {
321    /// Create a new `McpServer`.  The default MCP endpoint is `POST /mcp`.
322    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
323        McpServer {
324            server_name: name.into(),
325            server_version: version.into(),
326            path: "/mcp".to_string(),
327            tools: vec![],
328            resources: vec![],
329            prompts: vec![],
330            fallback: None,
331            auth_token: None,
332            sessions: Arc::new(Mutex::new(HashMap::new())),
333        }
334    }
335
336    /// Require a bearer token on every request to the MCP endpoint.
337    ///
338    /// The client must send `Authorization: Bearer <token>`. Requests with a
339    /// missing or wrong token receive `401 Unauthorized` before any JSON-RPC
340    /// processing occurs.
341    ///
342    /// Store the token in an environment variable — never hard-code it:
343    ///
344    /// ```rust,no_run
345    /// use rust_web_server::app::App;
346    /// use rust_web_server::core::New;
347    ///
348    /// let app = App::new()
349    ///     .mcp("my-server", "1.0")
350    ///     .require_bearer(std::env::var("MCP_TOKEN").expect("MCP_TOKEN not set"));
351    /// ```
352    ///
353    /// Claude Desktop config:
354    /// ```json
355    /// { "mcpServers": { "my-server": {
356    ///     "url": "http://localhost:7878/mcp",
357    ///     "headers": { "Authorization": "Bearer <token>" }
358    /// }}}
359    /// ```
360    pub fn require_bearer(mut self, token: impl Into<String>) -> Self {
361        self.auth_token = Some(token.into());
362        self
363    }
364
365    /// Wrap an existing [`Application`] so that non-MCP requests are forwarded
366    /// to it instead of the built-in [`App`].
367    ///
368    /// Use this when your existing server has custom routes, state, or
369    /// middleware that you want to keep alongside the MCP endpoint:
370    ///
371    /// ```rust,no_run
372    /// use rust_web_server::app::App;
373    /// use rust_web_server::mcp::{McpServer, McpContent};
374    /// use rust_web_server::response::{Response, STATUS_CODE_REASON_PHRASE};
375    /// use rust_web_server::test_client::TestClient;
376    ///
377    /// let existing_app = App::with_state(42u32)
378    ///     .get("/api/hello", |_req, _params, _conn, _state| {
379    ///         Response::get_response(&STATUS_CODE_REASON_PHRASE.n200_ok, None, None)
380    ///     });
381    ///
382    /// let server = McpServer::new("my-app", "1.0")
383    ///     .tool("ping", "Ping", "{}", |_| Ok(McpContent::text("pong")))
384    ///     .wrap(existing_app);
385    ///
386    /// // Both /mcp and /api/hello are now handled by the same server.
387    /// let client = TestClient::new(server);
388    /// ```
389    pub fn wrap(mut self, app: impl Application + Send + Sync + 'static) -> Self {
390        self.fallback = Some(Arc::new(app));
391        self
392    }
393
394    /// Override the HTTP path for the MCP endpoint (default `"/mcp"`).
395    pub fn at(mut self, path: impl Into<String>) -> Self {
396        self.path = path.into();
397        self
398    }
399
400    /// Register a callable tool.
401    ///
402    /// - `name` — tool identifier (snake_case recommended)
403    /// - `description` — human-readable description shown to the AI
404    /// - `input_schema` — JSON Schema object for the tool's arguments
405    /// - `handler` — closure receiving the raw `arguments` JSON string
406    ///
407    /// The handler returns [`McpContent`] on success or an error string.  An
408    /// error is returned to the client as `isError: true` (not a protocol error).
409    ///
410    /// Use [`Self::tool_with_context`] instead if the handler needs the
411    /// caller's identity, session, or headers.
412    pub fn tool<F>(mut self, name: &str, description: &str, input_schema: &str, handler: F) -> Self
413    where
414        F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
415    {
416        self.tools.push(ToolDef {
417            name: name.to_string(),
418            description: description.to_string(),
419            input_schema: input_schema.to_string(),
420            annotations: None,
421            handler: Arc::new(move |_ctx: McpContext, args: &str| handler(args)),
422        });
423        self
424    }
425
426    /// Register a callable tool with [`ToolAnnotations`] — behavioral hints
427    /// (read-only, destructive, idempotent, open-world) that MCP clients use
428    /// to decide whether to warn or confirm before calling it. Otherwise
429    /// identical to [`Self::tool`] — the handler still only receives
430    /// `arguments`, not [`McpContext`] (there is currently no single builder
431    /// combining annotations with per-request context; call [`Self::tool_with_context`]
432    /// instead if you need context and don't need annotations).
433    ///
434    /// ```rust,no_run
435    /// use rust_web_server::mcp::{McpContent, McpServer, ToolAnnotations};
436    ///
437    /// let server = McpServer::new("my-server", "1.0")
438    ///     .tool_annotated(
439    ///         "delete_file",
440    ///         "Delete a file from disk",
441    ///         r#"{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}"#,
442    ///         ToolAnnotations {
443    ///             destructive_hint: Some(true),
444    ///             read_only_hint: Some(false),
445    ///             idempotent_hint: Some(true), // deleting twice = deleting once
446    ///             ..Default::default()
447    ///         },
448    ///         |_args| Ok(McpContent::text("deleted")),
449    ///     );
450    /// ```
451    pub fn tool_annotated<F>(
452        mut self,
453        name: &str,
454        description: &str,
455        input_schema: &str,
456        annotations: ToolAnnotations,
457        handler: F,
458    ) -> Self
459    where
460        F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
461    {
462        self.tools.push(ToolDef {
463            name: name.to_string(),
464            description: description.to_string(),
465            input_schema: input_schema.to_string(),
466            annotations: Some(annotations),
467            handler: Arc::new(move |_ctx: McpContext, args: &str| handler(args)),
468        });
469        self
470    }
471
472    /// Register a callable tool whose handler also receives [`McpContext`] —
473    /// caller identity/session info derived from this request's headers and
474    /// whatever `clientInfo` this session sent at `initialize` time.
475    ///
476    /// Same `name`/`description`/`input_schema` semantics as [`Self::tool`];
477    /// the only difference is the handler's first parameter.
478    ///
479    /// ```rust,no_run
480    /// use rust_web_server::mcp::{McpContent, McpServer};
481    ///
482    /// let server = McpServer::new("my-server", "1.0")
483    ///     .tool_with_context(
484    ///         "whoami",
485    ///         "Report the caller's client info",
486    ///         "{}",
487    ///         |ctx, _args| {
488    ///             let name = ctx.client_name.as_deref().unwrap_or("unknown client");
489    ///             Ok(McpContent::text(format!("Called by {name}")))
490    ///         },
491    ///     );
492    /// ```
493    pub fn tool_with_context<F>(mut self, name: &str, description: &str, input_schema: &str, handler: F) -> Self
494    where
495        F: Fn(McpContext, &str) -> Result<McpContent, String> + Send + Sync + 'static,
496    {
497        self.tools.push(ToolDef {
498            name: name.to_string(),
499            description: description.to_string(),
500            input_schema: input_schema.to_string(),
501            annotations: None,
502            handler: Arc::new(handler),
503        });
504        self
505    }
506
507    /// Register a readable resource.
508    ///
509    /// `uri_template` uses `{param}` placeholders, e.g. `"user://{id}"`.
510    /// The handler receives the full concrete URI string.
511    pub fn resource<F>(mut self, uri_template: &str, name: &str, description: &str, handler: F) -> Self
512    where
513        F: Fn(&str) -> Result<McpContent, String> + Send + Sync + 'static,
514    {
515        self.resources.push(ResourceDef {
516            uri_template: uri_template.to_string(),
517            name: name.to_string(),
518            description: description.to_string(),
519            handler: Arc::new(handler),
520        });
521        self
522    }
523
524    /// Register a prompt template.
525    ///
526    /// The handler receives the raw `arguments` JSON string and returns a
527    /// list of [`PromptMessage`] values.
528    pub fn prompt<F>(mut self, name: &str, description: &str, handler: F) -> Self
529    where
530        F: Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync + 'static,
531    {
532        self.prompts.push(PromptDef {
533            name: name.to_string(),
534            description: description.to_string(),
535            arguments: vec![],
536            handler: Arc::new(handler),
537        });
538        self
539    }
540
541    /// Register a prompt template with explicit argument definitions.
542    pub fn prompt_with_args<F>(
543        mut self,
544        name: &str,
545        description: &str,
546        args: Vec<PromptArgDef>,
547        handler: F,
548    ) -> Self
549    where
550        F: Fn(&str) -> Result<Vec<PromptMessage>, String> + Send + Sync + 'static,
551    {
552        self.prompts.push(PromptDef {
553            name: name.to_string(),
554            description: description.to_string(),
555            arguments: args,
556            handler: Arc::new(handler),
557        });
558        self
559    }
560
561    // ── request dispatch ──────────────────────────────────────────────────────
562
563    /// Process a raw JSON-RPC body and return an HTTP response.
564    ///
565    /// Equivalent to [`Self::handle_request_with_context`] with an empty
566    /// [`McpContext`] — tool handlers registered via
567    /// [`Self::tool_with_context`] will see every field as `None`. Prefer
568    /// calling through [`Application::execute`] (i.e. actually serving HTTP
569    /// requests) when you need real per-request context; this method exists
570    /// for calling the JSON-RPC layer directly, e.g. in tests.
571    pub fn handle_request(&self, body: &str) -> Response {
572        self.handle_request_with_context(body, McpContext::default())
573    }
574
575    /// Process a raw JSON-RPC body with an explicit [`McpContext`] and return
576    /// an HTTP response. [`Self::execute`] calls this with a context built
577    /// from the request's headers and this session's stored `clientInfo`;
578    /// [`Self::handle_request`] calls this with an empty context.
579    ///
580    /// On a successful `initialize`, this mints a new session id (reusing
581    /// [`crate::request_id::generate_request_id`]'s ID generator), records
582    /// `params.clientInfo` under it, and returns the id in an
583    /// `Mcp-Session-Id` response header — the client is expected to echo that
584    /// header back on subsequent requests so later `tools/call`s in the same
585    /// session can look their `clientInfo` back up.
586    pub fn handle_request_with_context(&self, body: &str, ctx: McpContext) -> Response {
587        let method = match json_rpc::extract_str(body, "method") {
588            Some(m) => m,
589            None => return rpc_error(None, json_rpc::INVALID_REQUEST, "Missing method"),
590        };
591
592        let id = json_rpc::extract_id(body);
593
594        // Notifications have no `id` — acknowledge with 202 and no body.
595        if method == "notifications/initialized" || (id.is_none() && method != "ping") {
596            return no_content();
597        }
598
599        let result: Result<String, (i32, String)> = match method.as_str() {
600            "initialize"     => self.do_initialize(body),
601            "ping"           => Ok("{}".to_string()),
602            "tools/list"     => self.do_tools_list(),
603            "tools/call"     => self.do_tools_call(body, ctx),
604            "resources/list" => self.do_resources_list(),
605            "resources/read" => self.do_resources_read(body),
606            "prompts/list"   => self.do_prompts_list(),
607            "prompts/get"    => self.do_prompts_get(body),
608            _                => Err((json_rpc::METHOD_NOT_FOUND, format!("Unknown method: {method}"))),
609        };
610
611        let id_str = id.as_deref().unwrap_or("null");
612        let is_ok = result.is_ok();
613
614        let mut response = match result {
615            Ok(result_json) => json_response(&format!(
616                r#"{{"jsonrpc":"2.0","result":{result_json},"id":{id_str}}}"#
617            )),
618            Err((code, msg)) => {
619                let escaped = json_escape(&msg);
620                json_response(&format!(
621                    r#"{{"jsonrpc":"2.0","error":{{"code":{code},"message":"{escaped}"}},"id":{id_str}}}"#
622                ))
623            }
624        };
625
626        if method == "initialize" && is_ok {
627            self.start_session(body, &mut response);
628        }
629
630        response
631    }
632
633    /// Mint a new session id, record `body`'s `params.clientInfo` under it
634    /// (logging the caller's identity), and attach the id to `response` as
635    /// an `Mcp-Session-Id` header. Called once, from
636    /// [`Self::handle_request_with_context`], right after a successful
637    /// `initialize`.
638    fn start_session(&self, body: &str, response: &mut Response) {
639        let client_info = json_rpc::extract_raw(body, "params")
640            .and_then(|p| json_rpc::extract_raw(&p, "clientInfo"));
641        let (name, version) = match &client_info {
642            Some(info) => (
643                json_rpc::extract_str(info, "name"),
644                json_rpc::extract_str(info, "version"),
645            ),
646            None => (None, None),
647        };
648
649        eprintln!(
650            "[mcp] initialize from client {} v{}",
651            name.as_deref().unwrap_or("unknown"),
652            version.as_deref().unwrap_or("unknown"),
653        );
654
655        let session_id = crate::request_id::generate_request_id();
656        self.sessions
657            .lock()
658            .unwrap()
659            .insert(session_id.clone(), StoredClientInfo { name, version });
660
661        response.headers.push(Header {
662            name: "Mcp-Session-Id".to_string(),
663            value: session_id,
664        });
665    }
666
667    // ── method handlers ───────────────────────────────────────────────────────
668
669    /// Handle `initialize`. Per spec, the server must inspect the client's
670    /// requested `protocolVersion` and respond with the version it actually
671    /// supports — allowing the client to abort the session if incompatible —
672    /// rather than blindly echoing `PROTOCOL_VERSION` regardless of what was
673    /// asked for.
674    ///
675    /// This server implements exactly one protocol version, so "negotiation"
676    /// here means: if the client asked for that same version, confirm it;
677    /// otherwise tell the client the version we actually speak (older *or*
678    /// newer than what was requested), which is always the lower of the two
679    /// — version strings are `YYYY-MM-DD` dates, so a plain string comparison
680    /// already orders them correctly with no date parsing needed.
681    ///
682    /// `clientInfo` is *not* handled here — [`Self::handle_request_with_context`]
683    /// extracts and stores it (under a freshly minted session id) after this
684    /// returns, so it's only ever parsed out of `body` once per call.
685    fn do_initialize(&self, body: &str) -> Result<String, (i32, String)> {
686        let params = json_rpc::extract_raw(body, "params");
687
688        let client_version = params.as_deref().and_then(|p| json_rpc::extract_str(p, "protocolVersion"));
689
690        let negotiated_version: &str = match client_version.as_deref() {
691            Some(v) if v < PROTOCOL_VERSION => v,
692            _ => PROTOCOL_VERSION,
693        };
694
695        let caps = format!(
696            r#"{{"tools":{{"listChanged":false}},"resources":{{"subscribe":false,"listChanged":false}},"prompts":{{"listChanged":false}}}}"#
697        );
698        Ok(format!(
699            r#"{{"protocolVersion":"{}","capabilities":{caps},"serverInfo":{{"name":"{}","version":"{}"}}}}"#,
700            json_escape(negotiated_version),
701            json_escape(&self.server_name),
702            json_escape(&self.server_version),
703        ))
704    }
705
706    fn do_tools_list(&self) -> Result<String, (i32, String)> {
707        let items: Vec<String> = self.tools.iter().map(|t| {
708            let annotations = match t.annotations {
709                Some(a) => format!(r#","annotations":{}"#, a.to_json()),
710                None => String::new(),
711            };
712            format!(
713                r#"{{"name":"{}","description":"{}","inputSchema":{}{}}}"#,
714                json_escape(&t.name),
715                json_escape(&t.description),
716                t.input_schema,
717                annotations,
718            )
719        }).collect();
720        Ok(format!(r#"{{"tools":[{}]}}"#, items.join(",")))
721    }
722
723    fn do_tools_call(&self, body: &str, ctx: McpContext) -> Result<String, (i32, String)> {
724        let params = json_rpc::extract_raw(body, "params")
725            .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
726        let name = json_rpc::extract_str(&params, "name")
727            .ok_or((json_rpc::INVALID_PARAMS, "Missing tool name".to_string()))?;
728        let args = json_rpc::extract_raw(&params, "arguments")
729            .unwrap_or_else(|| "{}".to_string());
730
731        let tool = self.tools.iter().find(|t| t.name == name)
732            .ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Unknown tool: {name}")))?;
733
734        match (tool.handler)(ctx, &args) {
735            Ok(c) => Ok(format!(
736                r#"{{"content":[{}],"isError":false}}"#,
737                c.to_content_json(),
738            )),
739            Err(e) => {
740                let escaped = json_escape(&e);
741                Ok(format!(
742                    r#"{{"content":[{{"type":"text","text":"{escaped}"}}],"isError":true}}"#
743                ))
744            }
745        }
746    }
747
748    fn do_resources_list(&self) -> Result<String, (i32, String)> {
749        let items: Vec<String> = self.resources.iter().map(|r| {
750            format!(
751                r#"{{"uri":"{}","name":"{}","description":"{}","mimeType":"text/plain"}}"#,
752                json_escape(&r.uri_template),
753                json_escape(&r.name),
754                json_escape(&r.description),
755            )
756        }).collect();
757        Ok(format!(r#"{{"resources":[{}]}}"#, items.join(",")))
758    }
759
760    fn do_resources_read(&self, body: &str) -> Result<String, (i32, String)> {
761        let params = json_rpc::extract_raw(body, "params")
762            .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
763        let uri = json_rpc::extract_str(&params, "uri")
764            .ok_or((json_rpc::INVALID_PARAMS, "Missing uri".to_string()))?;
765
766        let resource = self.resources.iter().find(|r| uri_matches(&r.uri_template, &uri))
767            .ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Resource not found: {uri}")))?;
768
769        match (resource.handler)(&uri) {
770            Ok(c) => {
771                let text_esc = json_escape(&c.text);
772                let uri_esc  = json_escape(&uri);
773                Ok(format!(
774                    r#"{{"contents":[{{"uri":"{uri_esc}","mimeType":"{}","text":"{text_esc}"}}]}}"#,
775                    c.mime(),
776                ))
777            }
778            Err(e) => Err((json_rpc::INVALID_PARAMS, e)),
779        }
780    }
781
782    fn do_prompts_list(&self) -> Result<String, (i32, String)> {
783        let items: Vec<String> = self.prompts.iter().map(|p| {
784            let arg_defs: Vec<String> = p.arguments.iter().map(|a| {
785                format!(
786                    r#"{{"name":"{}","description":"{}","required":{}}}"#,
787                    json_escape(&a.name),
788                    json_escape(&a.description),
789                    a.required,
790                )
791            }).collect();
792            format!(
793                r#"{{"name":"{}","description":"{}","arguments":[{}]}}"#,
794                json_escape(&p.name),
795                json_escape(&p.description),
796                arg_defs.join(","),
797            )
798        }).collect();
799        Ok(format!(r#"{{"prompts":[{}]}}"#, items.join(",")))
800    }
801
802    fn do_prompts_get(&self, body: &str) -> Result<String, (i32, String)> {
803        let params = json_rpc::extract_raw(body, "params")
804            .ok_or((json_rpc::INVALID_PARAMS, "Missing params".to_string()))?;
805        let name = json_rpc::extract_str(&params, "name")
806            .ok_or((json_rpc::INVALID_PARAMS, "Missing prompt name".to_string()))?;
807        let args = json_rpc::extract_raw(&params, "arguments")
808            .unwrap_or_else(|| "{}".to_string());
809
810        let prompt = self.prompts.iter().find(|p| p.name == name)
811            .ok_or_else(|| (json_rpc::INVALID_PARAMS, format!("Unknown prompt: {name}")))?;
812
813        match (prompt.handler)(&args) {
814            Ok(msgs) => {
815                let msg_jsons: Vec<String> = msgs.iter().map(|m| m.to_json()).collect();
816                Ok(format!(
817                    r#"{{"description":"{}","messages":[{}]}}"#,
818                    json_escape(&prompt.description),
819                    msg_jsons.join(","),
820                ))
821            }
822            Err(e) => Err((json_rpc::INVALID_PARAMS, e)),
823        }
824    }
825
826    /// Build the [`McpContext`] for an incoming request: the `Mcp-Session-Id`
827    /// header, if present, plus whatever `clientInfo` was recorded for that
828    /// session at `initialize` time (if this session is recognized).
829    fn context_for(&self, request: &Request) -> McpContext {
830        let session_id = request
831            .get_header("Mcp-Session-Id".to_string())
832            .map(|h| h.value.clone());
833
834        let (client_name, client_version) = match &session_id {
835            Some(sid) => match self.sessions.lock().unwrap().get(sid) {
836                Some(info) => (info.name.clone(), info.version.clone()),
837                None => (None, None),
838            },
839            None => (None, None),
840        };
841
842        McpContext { client_name, client_version, session_id, auth_claims: None }
843    }
844}
845
846// ── Application ───────────────────────────────────────────────────────────────
847
848impl Application for McpServer {
849    fn execute(&self, request: &Request, connection: &ConnectionInfo) -> Result<Response, String> {
850        if request.request_uri == self.path {
851            // Check bearer token before processing any MCP request.
852            if let Some(expected) = &self.auth_token {
853                let provided = request.headers.iter()
854                    .find(|h| h.name.eq_ignore_ascii_case("authorization"))
855                    .map(|h| h.value.as_str())
856                    .unwrap_or("");
857                let bearer = provided.strip_prefix("Bearer ").unwrap_or("");
858                if bearer != expected.as_str() {
859                    return Ok(unauthorized());
860                }
861            }
862
863            return Ok(match request.method.as_str() {
864                "POST" => {
865                    let body = std::str::from_utf8(&request.body).unwrap_or("");
866                    let ctx = self.context_for(request);
867                    self.handle_request_with_context(body, ctx)
868                }
869                "OPTIONS" => {
870                    // CORS preflight for browser-based MCP clients
871                    let mut r = Response::new();
872                    r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
873                    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
874                    r.headers.push(Header {
875                        name: "Allow".to_string(),
876                        value: "POST, OPTIONS".to_string(),
877                    });
878                    r
879                }
880                _ => {
881                    let mut r = Response::new();
882                    r.status_code = *STATUS_CODE_REASON_PHRASE.n405_method_not_allowed.status_code;
883                    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n405_method_not_allowed.reason_phrase.to_string();
884                    r.headers.push(Header {
885                        name: "Allow".to_string(),
886                        value: "POST, OPTIONS".to_string(),
887                    });
888                    r.content_range_list = vec![Range::get_content_range(
889                        b"MCP endpoint only accepts POST".to_vec(),
890                        MimeType::TEXT_PLAIN.to_string(),
891                    )];
892                    r
893                }
894            });
895        }
896
897        // Not an MCP path — fall through to the wrapped app (or built-in App).
898        match &self.fallback {
899            Some(app) => app.execute(request, connection),
900            None      => App::new().execute(request, connection),
901        }
902    }
903}
904
905// ── public helper ─────────────────────────────────────────────────────────────
906
907/// Extract a string argument from a tool/prompt `arguments` JSON object.
908///
909/// ```rust
910/// use rust_web_server::mcp::extract_arg;
911/// assert_eq!(extract_arg(r#"{"text":"hello"}"#, "text").as_deref(), Some("hello"));
912/// assert_eq!(extract_arg(r#"{}"#, "missing"), None);
913/// ```
914pub fn extract_arg(arguments: &str, name: &str) -> Option<String> {
915    json_rpc::extract_str(arguments, name)
916}
917
918// ── internal helpers ──────────────────────────────────────────────────────────
919
920fn json_response(body: &str) -> Response {
921    let mut r = Response::new();
922    r.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
923    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
924    r.content_range_list = vec![Range::get_content_range(
925        body.as_bytes().to_vec(),
926        MimeType::APPLICATION_JSON.to_string(),
927    )];
928    r
929}
930
931fn no_content() -> Response {
932    let mut r = Response::new();
933    r.status_code = *STATUS_CODE_REASON_PHRASE.n202_accepted.status_code;
934    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n202_accepted.reason_phrase.to_string();
935    r
936}
937
938fn unauthorized() -> Response {
939    let mut r = Response::new();
940    r.status_code = *STATUS_CODE_REASON_PHRASE.n401_unauthorized.status_code;
941    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string();
942    r.headers.push(Header {
943        name: "WWW-Authenticate".to_string(),
944        value: "Bearer".to_string(),
945    });
946    r.content_range_list = vec![Range::get_content_range(
947        b"Unauthorized".to_vec(),
948        MimeType::TEXT_PLAIN.to_string(),
949    )];
950    r
951}
952
953fn rpc_error(id: Option<&str>, code: i32, message: &str) -> Response {
954    let id_str  = id.unwrap_or("null");
955    let escaped = json_escape(message);
956    json_response(&format!(
957        r#"{{"jsonrpc":"2.0","error":{{"code":{code},"message":"{escaped}"}},"id":{id_str}}}"#
958    ))
959}
960
961pub(crate) fn json_escape(s: &str) -> String {
962    let mut out = String::with_capacity(s.len() + 4);
963    for ch in s.chars() {
964        match ch {
965            '"'  => out.push_str("\\\""),
966            '\\' => out.push_str("\\\\"),
967            '\n' => out.push_str("\\n"),
968            '\r' => out.push_str("\\r"),
969            '\t' => out.push_str("\\t"),
970            c if (c as u32) < 0x20 => { let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{:04x}", c as u32)); }
971            c    => out.push(c),
972        }
973    }
974    out
975}
976
977fn uri_matches(template: &str, uri: &str) -> bool {
978    // Template `"user://{id}"` matches any URI starting with `"user://"`.
979    match template.find('{') {
980        Some(pos) => uri.starts_with(&template[..pos]),
981        None      => template == uri,
982    }
983}