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(¶ms, "name")
727 .ok_or((json_rpc::INVALID_PARAMS, "Missing tool name".to_string()))?;
728 let args = json_rpc::extract_raw(¶ms, "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(¶ms, "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(¶ms, "name")
806 .ok_or((json_rpc::INVALID_PARAMS, "Missing prompt name".to_string()))?;
807 let args = json_rpc::extract_raw(¶ms, "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}