Skip to main content

vtcode_webmcp/remote_mcp/
mod.rs

1//! Shared MCP tools and local stdio/authenticated HTTP transports, read-only by default.
2//!
3//! The remote surface deliberately shares the [`RuntimeAdapter`] read boundary
4//! with the browser bridge, but it has an independent authentication and Origin
5//! policy. A TLS-terminating reverse proxy is expected to validate the external
6//! OAuth bearer token and inject the configured internal bearer token.
7
8use crate::companion::{CompanionTasks, StartTaskInput, TaskInput, TaskSnapshot};
9use crate::error::{Result as WebmcpResult, WebmcpError};
10use crate::runtime::RuntimeAdapter;
11use axum::Router;
12use axum::body::{self, Body};
13use axum::extract::{Path, Request, State};
14use axum::http::header::{
15    ACCEPT, AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE, HOST, ORIGIN, PROXY_AUTHORIZATION, WWW_AUTHENTICATE,
16};
17use axum::http::{HeaderMap, HeaderValue, Method, StatusCode};
18use axum::middleware::{self, Next};
19use axum::response::sse::{Event, KeepAlive, Sse};
20use axum::response::{IntoResponse, Json as AxumJson, Response};
21use axum::routing::{get, post};
22use futures::stream;
23use futures::{Sink, Stream, StreamExt};
24use rmcp::handler::server::router::tool::ToolRouter;
25use rmcp::handler::server::wrapper::Parameters;
26use rmcp::model::{ClientJsonRpcMessage, Implementation, ServerCapabilities, ServerConfig, ServerJsonRpcMessage, Tool};
27use rmcp::transport::sink_stream::SinkStreamTransport;
28use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
29use rmcp::transport::streamable_http_server::{StreamableHttpServerConfig, StreamableHttpService};
30use rmcp::{Json, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
31use std::collections::BTreeMap;
32use std::convert::Infallible;
33use std::error::Error;
34use std::fmt::{Display, Formatter};
35use std::pin::Pin;
36use std::sync::Arc;
37use std::task::{Context, Poll};
38use std::time::{Duration, Instant};
39use tokio::sync::{Mutex, mpsc};
40use tokio::time::sleep;
41use tokio_stream::wrappers::ReceiverStream;
42use tokio_util::sync::{CancellationToken, DropGuard};
43use url::Url;
44use uuid::Uuid;
45use vtcode_commons::validation::is_valid_origin;
46
47const DEFAULT_MAX_RESULTS: usize = 20;
48const DEFAULT_MAX_SCAN_FILES: usize = 256;
49const DEFAULT_MAX_SCAN_BYTES: usize = 16 * 1024 * 1024;
50const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 1024 * 1024;
51const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
52const MAX_LEGACY_SESSIONS: usize = 64;
53const LEGACY_INPUT_QUEUE_CAPACITY: usize = 16;
54const MAX_QUERY_BYTES: usize = 4096;
55const MAX_FILE_ID_BYTES: usize = 4096;
56const LEGACY_KEEP_ALIVE: Duration = Duration::from_secs(15);
57const PROTECTED_RESOURCE_METADATA_PATH: &str = "/.well-known/oauth-protected-resource";
58
59mod types;
60
61pub use types::{FetchInput, FetchOutput, SearchInput, SearchOutput, SearchResult};
62
63/// Runtime configuration for the remote MCP transport.
64///
65/// The proxy bearer token is intentionally private and is omitted from the
66/// [`Debug`] implementation. It must never be copied into a URL or persisted.
67#[derive(Clone)]
68pub struct RemoteMcpServerConfig {
69    /// Canonical external HTTPS URL, normally the legacy `/sse/` endpoint.
70    pub public_url: Url,
71    /// External authorization server URL advertised in protected-resource metadata.
72    pub authorization_server: Url,
73    proxy_bearer_token: Arc<str>,
74    /// Optional URL prefix for escaped file citations.
75    pub citation_url_prefix: Option<Url>,
76    /// Separate allowlist for supplied MCP Origin headers.
77    pub allowed_origins: Vec<String>,
78    /// Maximum number of search results.
79    pub max_results: usize,
80    /// Maximum files inspected by search.
81    pub max_scan_files: usize,
82    /// Maximum UTF-8 content bytes inspected by search.
83    pub max_scan_bytes: usize,
84    /// Maximum legacy MCP request body size.
85    pub max_request_body_bytes: usize,
86    /// Legacy SSE session inactivity lifetime.
87    pub session_ttl: Duration,
88    /// Optional binary-owned execution service. Absent keeps the server read-only.
89    pub companion: Option<Arc<dyn CompanionTasks>>,
90}
91
92impl std::fmt::Debug for RemoteMcpServerConfig {
93    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
94        formatter
95            .debug_struct("RemoteMcpServerConfig")
96            .field("public_url", &self.public_url)
97            .field("authorization_server", &self.authorization_server)
98            .field("proxy_bearer_token", &"<redacted>")
99            .field("citation_url_prefix", &self.citation_url_prefix)
100            .field("allowed_origins", &self.allowed_origins)
101            .field("max_results", &self.max_results)
102            .field("max_scan_files", &self.max_scan_files)
103            .field("max_scan_bytes", &self.max_scan_bytes)
104            .field("max_request_body_bytes", &self.max_request_body_bytes)
105            .field("session_ttl", &self.session_ttl)
106            .finish()
107    }
108}
109
110impl RemoteMcpServerConfig {
111    /// Create a validated remote MCP configuration with the default bounds.
112    pub fn new(
113        public_url: impl AsRef<str>,
114        authorization_server: impl AsRef<str>,
115        proxy_bearer_token: impl Into<String>,
116    ) -> WebmcpResult<Self> {
117        let public_url = Url::parse(public_url.as_ref())
118            .map_err(|_error| WebmcpError::InvalidRequest("remote MCP public URL is invalid".to_string()))?;
119        let authorization_server = Url::parse(authorization_server.as_ref()).map_err(|_error| {
120            WebmcpError::InvalidRequest("remote MCP authorization-server URL is invalid".to_string())
121        })?;
122        let proxy_bearer_token: Arc<str> = proxy_bearer_token.into().into();
123        let config = Self {
124            public_url,
125            authorization_server,
126            proxy_bearer_token,
127            citation_url_prefix: None,
128            allowed_origins: Vec::new(),
129            max_results: DEFAULT_MAX_RESULTS,
130            max_scan_files: DEFAULT_MAX_SCAN_FILES,
131            max_scan_bytes: DEFAULT_MAX_SCAN_BYTES,
132            max_request_body_bytes: DEFAULT_MAX_REQUEST_BODY_BYTES,
133            session_ttl: DEFAULT_SESSION_TTL,
134            companion: None,
135        };
136        config.validate()?;
137        Ok(config)
138    }
139
140    /// Validate URL, authentication, and resource bounds.
141    pub fn validate(&self) -> WebmcpResult<()> {
142        if !is_valid_https_url(&self.public_url) {
143            return Err(WebmcpError::InvalidRequest(
144                "remote MCP public URL must be absolute HTTPS without credentials, query, or fragment".to_string(),
145            ));
146        }
147        if !is_valid_https_url(&self.authorization_server) {
148            return Err(WebmcpError::InvalidRequest(
149                "remote MCP authorization-server URL must be absolute HTTPS without credentials, query, or fragment"
150                    .to_string(),
151            ));
152        }
153        if self.proxy_bearer_token.is_empty()
154            || !self.proxy_bearer_token.is_ascii()
155            || self.proxy_bearer_token.chars().any(|character| character.is_ascii_whitespace())
156        {
157            return Err(WebmcpError::InvalidRequest(
158                "remote MCP proxy bearer token must be a non-empty ASCII token".to_string(),
159            ));
160        }
161        if let Some(prefix) = self.citation_url_prefix.as_ref()
162            && !is_valid_citation_url_prefix(prefix)
163        {
164            return Err(WebmcpError::InvalidRequest(
165                "remote MCP citation URL prefix must be an absolute HTTP(S) URL without credentials, query, or fragment"
166                    .to_string(),
167            ));
168        }
169        if self.allowed_origins.iter().any(|origin| !is_valid_origin(origin)) {
170            return Err(WebmcpError::InvalidRequest("remote MCP origins must be explicit HTTP(S) origins".to_string()));
171        }
172        if self.max_results == 0 || self.max_results > 100 {
173            return Err(WebmcpError::LimitExceeded);
174        }
175        if self.max_scan_files == 0 || self.max_scan_files > 4096 {
176            return Err(WebmcpError::LimitExceeded);
177        }
178        if self.max_scan_bytes == 0 || self.max_scan_bytes > 64 * 1024 * 1024 {
179            return Err(WebmcpError::LimitExceeded);
180        }
181        if self.max_request_body_bytes == 0 || self.max_request_body_bytes > 16 * 1024 * 1024 {
182            return Err(WebmcpError::LimitExceeded);
183        }
184        if self.session_ttl.is_zero() || self.session_ttl > Duration::from_secs(3600) {
185            return Err(WebmcpError::InvalidRequest(
186                "remote MCP session TTL must be between 1 and 3600 seconds".to_string(),
187            ));
188        }
189        Ok(())
190    }
191
192    /// Set the citation prefix after parsing it as a URL.
193    pub fn with_citation_url_prefix(mut self, prefix: Option<Url>) -> WebmcpResult<Self> {
194        self.citation_url_prefix = prefix;
195        self.validate()?;
196        Ok(self)
197    }
198
199    /// Set the separate MCP Origin allowlist.
200    pub fn with_allowed_origins(mut self, origins: Vec<String>) -> WebmcpResult<Self> {
201        self.allowed_origins = origins;
202        self.validate()?;
203        Ok(self)
204    }
205
206    /// Set the search and legacy transport bounds.
207    pub fn with_limits(
208        mut self,
209        max_results: usize,
210        max_scan_files: usize,
211        max_scan_bytes: usize,
212        max_request_body_bytes: usize,
213        session_ttl: Duration,
214    ) -> WebmcpResult<Self> {
215        self.max_results = max_results;
216        self.max_scan_files = max_scan_files;
217        self.max_scan_bytes = max_scan_bytes;
218        self.max_request_body_bytes = max_request_body_bytes;
219        self.session_ttl = session_ttl;
220        self.validate()?;
221        Ok(self)
222    }
223
224    fn proxy_bearer_token(&self) -> &str {
225        &self.proxy_bearer_token
226    }
227
228    fn metadata_url(&self) -> Url {
229        let mut metadata_url = self.public_url.clone();
230        metadata_url.set_path(PROTECTED_RESOURCE_METADATA_PATH);
231        metadata_url.set_query(None);
232        metadata_url.set_fragment(None);
233        metadata_url
234    }
235}
236
237fn is_valid_https_url(url: &Url) -> bool {
238    url.scheme() == "https"
239        && url.host_str().is_some_and(|host| !host.is_empty())
240        && url.username().is_empty()
241        && url.password().is_none()
242        && url.query().is_none()
243        && url.fragment().is_none()
244}
245
246fn is_valid_citation_url_prefix(url: &Url) -> bool {
247    matches!(url.scheme(), "http" | "https")
248        && url.host_str().is_some_and(|host| !host.is_empty())
249        && url.username().is_empty()
250        && url.password().is_none()
251        && url.query().is_none()
252        && url.fragment().is_none()
253}
254
255/// MCP handler backed by workspace reads and an optional binary-owned task service.
256#[derive(Clone)]
257pub struct RemoteMcpHandler {
258    adapter: Arc<dyn RuntimeAdapter>,
259    max_results: usize,
260    max_scan_files: usize,
261    max_scan_bytes: usize,
262    citation_url_prefix: Option<Url>,
263    companion: Option<Arc<dyn CompanionTasks>>,
264    tool_router: ToolRouter<Self>,
265}
266
267impl std::fmt::Debug for RemoteMcpHandler {
268    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
269        formatter
270            .debug_struct("RemoteMcpHandler")
271            .field("max_results", &self.max_results)
272            .field("companion_enabled", &self.companion.is_some())
273            .finish_non_exhaustive()
274    }
275}
276
277#[tool_router]
278impl RemoteMcpHandler {
279    /// Create a handler around the shared read-only runtime boundary.
280    pub fn new(adapter: Arc<dyn RuntimeAdapter>, config: Arc<RemoteMcpServerConfig>) -> Self {
281        let mut handler = Self::local(adapter, config.companion.clone());
282        handler.max_results = config.max_results;
283        handler.max_scan_files = config.max_scan_files;
284        handler.max_scan_bytes = config.max_scan_bytes;
285        handler.citation_url_prefix = config.citation_url_prefix.clone();
286        handler
287    }
288
289    /// Create a local handler with default read bounds and no HTTP authentication state.
290    /// Access is controlled by the owning process and the workspace execution policy.
291    pub fn local(adapter: Arc<dyn RuntimeAdapter>, companion: Option<Arc<dyn CompanionTasks>>) -> Self {
292        let mut tool_router = Self::tool_router();
293        if companion.is_none() {
294            for name in ["start_task", "get_task", "cancel_task"] {
295                tool_router.remove_route(name);
296            }
297        }
298        Self {
299            adapter,
300            max_results: DEFAULT_MAX_RESULTS,
301            max_scan_files: DEFAULT_MAX_SCAN_FILES,
302            max_scan_bytes: DEFAULT_MAX_SCAN_BYTES,
303            citation_url_prefix: None,
304            companion,
305            tool_router,
306        }
307    }
308
309    /// Serve local MCP messages until EOF or cancellation. Stdout is protocol-only.
310    pub async fn serve_stdio(self, cancellation: CancellationToken) -> WebmcpResult<()> {
311        let service = self
312            .serve_with_ct(rmcp::transport::stdio(), cancellation)
313            .await
314            .map_err(|error| WebmcpError::Adapter(format!("local MCP initialization failed: {error}")))?;
315        let reason = service
316            .waiting()
317            .await
318            .map_err(|error| WebmcpError::Adapter(format!("local MCP service stopped: {error}")))?;
319        if let rmcp::service::QuitReason::JoinError(error) = reason {
320            return Err(WebmcpError::Adapter(format!("local MCP worker failed: {error}")));
321        }
322        Ok(())
323    }
324
325    /// Return the exact tool definitions advertised by this handler.
326    pub fn tool_definitions(&self) -> Vec<Tool> {
327        self.tool_router.list_all()
328    }
329
330    /// Search visible workspace files by deterministic case-insensitive substring.
331    #[tool(
332        name = "search",
333        description = "Search visible UTF-8 workspace files by case-insensitive substring.",
334        annotations(
335            title = "Search workspace files",
336            read_only_hint = true,
337            destructive_hint = false,
338            idempotent_hint = true,
339            open_world_hint = false
340        )
341    )]
342    pub async fn search(&self, Parameters(input): Parameters<SearchInput>) -> Result<Json<SearchOutput>, String> {
343        let query = input.query.trim();
344        if query.is_empty() {
345            return Ok(Json(SearchOutput { results: Vec::new() }));
346        }
347        if query.len() > MAX_QUERY_BYTES {
348            return Err("search query exceeds the configured input limit".to_string());
349        }
350        let query = query.to_lowercase();
351        let mut files = self
352            .adapter
353            .list_files()
354            .await
355            .map_err(|_error| "workspace search is unavailable".to_string())?;
356        files.sort_unstable_by(|left, right| left.path.cmp(&right.path));
357
358        let mut results = Vec::with_capacity(self.max_results.min(DEFAULT_MAX_RESULTS));
359        let mut scanned_bytes = 0usize;
360        for file in files.into_iter().take(self.max_scan_files) {
361            let path_lower = file.path.to_lowercase();
362            let path_matches = path_lower.contains(&query);
363            if path_matches {
364                results.push(self.search_result(&file.path));
365                if results.len() >= self.max_results {
366                    break;
367                }
368                continue;
369            }
370
371            let declared_size = match usize::try_from(file.size_bytes) {
372                Ok(size) => size,
373                Err(_) => continue,
374            };
375            let remaining_bytes = self.max_scan_bytes.saturating_sub(scanned_bytes);
376            if declared_size > remaining_bytes {
377                continue;
378            }
379            let snapshot = match self.adapter.read_file(&file.path).await {
380                Ok(snapshot) if snapshot.path == file.path => snapshot,
381                Ok(_) | Err(_) => continue,
382            };
383            if snapshot.content.len() > remaining_bytes {
384                continue;
385            }
386            scanned_bytes = scanned_bytes.saturating_add(snapshot.content.len());
387            if snapshot.content.to_lowercase().contains(&query) {
388                results.push(self.search_result(&file.path));
389                if results.len() >= self.max_results {
390                    break;
391                }
392            }
393        }
394        Ok(Json(SearchOutput { results }))
395    }
396
397    /// Fetch one visible workspace file by the ID returned from `search`.
398    #[tool(
399        name = "fetch",
400        description = "Fetch the UTF-8 text of one visible workspace file by ID.",
401        annotations(
402            title = "Fetch workspace file",
403            read_only_hint = true,
404            destructive_hint = false,
405            idempotent_hint = true,
406            open_world_hint = false
407        )
408    )]
409    pub async fn fetch(&self, Parameters(input): Parameters<FetchInput>) -> Result<Json<FetchOutput>, String> {
410        let id = input.id.as_str();
411        if id.trim().is_empty() {
412            return Err("file id is required".to_string());
413        }
414        if id.len() > MAX_FILE_ID_BYTES {
415            return Err("file id exceeds the configured input limit".to_string());
416        }
417        let visible = self
418            .adapter
419            .list_files()
420            .await
421            .map_err(|_error| "workspace file listing is unavailable".to_string())?;
422        if !visible.iter().any(|file| file.path == id) {
423            return Err("requested workspace file is unavailable".to_string());
424        }
425        let snapshot = self
426            .adapter
427            .read_file(id)
428            .await
429            .map_err(|_error| "requested workspace file is unavailable".to_string())?;
430        if snapshot.path != id {
431            return Err("requested workspace file is unavailable".to_string());
432        }
433        let canonical_id = snapshot.path;
434        Ok(Json(FetchOutput {
435            id: canonical_id.clone(),
436            title: canonical_id.clone(),
437            text: snapshot.content,
438            url: self.citation_url(&canonical_id),
439            metadata: None,
440        }))
441    }
442
443    /// Start a plan or explicitly requested implementation within the configured workspace.
444    #[tool(
445        name = "start_task",
446        description = "Start a VT Code task. Defaults to read-only planning. Implementation requires a completed plan_task_id and an explicit user request.",
447        annotations(
448            read_only_hint = false,
449            destructive_hint = true,
450            idempotent_hint = false,
451            open_world_hint = true
452        )
453    )]
454    pub async fn start_task(
455        &self,
456        Parameters(input): Parameters<StartTaskInput>,
457    ) -> Result<Json<TaskSnapshot>, String> {
458        self.companion()?.start(input).await.map(Json)
459    }
460
461    /// Return bounded, public task evidence.
462    #[tool(
463        name = "get_task",
464        description = "Inspect task state, attributed changes, scoped runtime verification, and bounded historical command_results. Command exit zero does not establish unrelated task assertions. Missing proof remains unverified.",
465        annotations(
466            read_only_hint = true,
467            destructive_hint = false,
468            idempotent_hint = true,
469            open_world_hint = false
470        )
471    )]
472    pub async fn get_task(&self, Parameters(input): Parameters<TaskInput>) -> Result<Json<TaskSnapshot>, String> {
473        self.companion()?.get(&input.task_id).await.map(Json)
474    }
475
476    /// Cancel only a task owned by this service instance.
477    #[tool(
478        name = "cancel_task",
479        description = "Request cancellation of an owned VT Code task. Poll get_task until terminal cleanup completes.",
480        annotations(
481            read_only_hint = false,
482            destructive_hint = true,
483            idempotent_hint = true,
484            open_world_hint = false
485        )
486    )]
487    pub async fn cancel_task(&self, Parameters(input): Parameters<TaskInput>) -> Result<Json<bool>, String> {
488        self.companion()?.cancel(&input.task_id).await.map(Json)
489    }
490
491    fn companion(&self) -> Result<&dyn CompanionTasks, String> {
492        self.companion
493            .as_deref()
494            .ok_or_else(|| "companion execution is disabled".into())
495    }
496
497    fn search_result(&self, path: &str) -> SearchResult {
498        SearchResult {
499            id: path.to_string(),
500            title: path.to_string(),
501            url: self.citation_url(path),
502        }
503    }
504
505    fn citation_url(&self, path: &str) -> String {
506        let Some(prefix) = self.citation_url_prefix.as_ref() else {
507            return String::new();
508        };
509        let mut url = prefix.clone();
510        if let Ok(mut segments) = url.path_segments_mut() {
511            let _ = segments.pop_if_empty();
512            for segment in path.split('/') {
513                let _ = segments.push(segment);
514            }
515        }
516        url.to_string()
517    }
518}
519
520#[tool_handler(router = self.tool_router)]
521impl ServerHandler for RemoteMcpHandler {
522    fn get_info(&self) -> ServerConfig {
523        ServerConfig::new(ServerCapabilities::builder().enable_tools().build())
524            .with_server_info(Implementation::new("vtcode-webmcp", env!("CARGO_PKG_VERSION")))
525            .with_instructions(
526                if self.companion.is_some() {
527                    "This server exposes workspace search/fetch and opt-in VT Code tasks. Plan first; implement only on explicit user request. Poll task results and report verification evidence accurately."
528                } else {
529                    "This server exposes only read-only search and fetch tools over the configured workspace."
530                },
531            )
532    }
533}
534
535/// Remote MCP HTTP endpoint collection mounted by [`crate::WebmcpServer`].
536pub struct RemoteMcpEndpoint {
537    config: Arc<RemoteMcpServerConfig>,
538    handler: RemoteMcpHandler,
539    streamable: StreamableHttpService<RemoteMcpHandler, LocalSessionManager>,
540    legacy_sessions: LegacySessionStore,
541}
542
543impl std::fmt::Debug for RemoteMcpEndpoint {
544    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
545        formatter
546            .debug_struct("RemoteMcpEndpoint")
547            .field("config", &self.config)
548            .finish_non_exhaustive()
549    }
550}
551
552impl RemoteMcpEndpoint {
553    /// Construct the modern Streamable HTTP and legacy HTTP+SSE services.
554    pub fn new(adapter: Arc<dyn RuntimeAdapter>, config: RemoteMcpServerConfig) -> WebmcpResult<Self> {
555        config.validate()?;
556        let config = Arc::new(config);
557        let service_config = StreamableHttpServerConfig::default()
558            .with_json_response(true)
559            .with_legacy_session_mode(false)
560            .with_allowed_hosts(allowed_hosts(&config.public_url))
561            .with_allowed_origins(config.allowed_origins.clone())
562            .with_max_request_body_bytes(config.max_request_body_bytes);
563        let handler = RemoteMcpHandler::new(Arc::clone(&adapter), Arc::clone(&config));
564        let handler_adapter = Arc::clone(&adapter);
565        let handler_config = Arc::clone(&config);
566        let streamable = StreamableHttpService::new(
567            move || Ok(RemoteMcpHandler::new(Arc::clone(&handler_adapter), Arc::clone(&handler_config))),
568            Arc::new(LocalSessionManager::default()),
569            service_config,
570        );
571        Ok(Self {
572            legacy_sessions: LegacySessionStore::new(config.session_ttl),
573            config,
574            handler,
575            streamable,
576        })
577    }
578
579    /// Return the public configuration without exposing the proxy token.
580    pub fn config(&self) -> &RemoteMcpServerConfig {
581        &self.config
582    }
583
584    /// Return the advertised tools (read-only unless companion is explicitly configured).
585    pub fn tool_definitions(&self) -> Vec<Tool> {
586        self.handler.tool_definitions()
587    }
588
589    /// Build routes before the containing WebMCP server supplies its state.
590    pub fn routes(self: &Arc<Self>) -> Router<Arc<Self>> {
591        Router::new()
592            .nest_service("/mcp", self.streamable.clone())
593            .route("/sse/", get(legacy_sse_handler))
594            .route("/sse", get(legacy_sse_handler))
595            .route("/messages/{session_id}", post(legacy_message_handler))
596            .route(PROTECTED_RESOURCE_METADATA_PATH, get(protected_resource_metadata))
597            .layer(middleware::from_fn_with_state(self.clone(), authenticate_request))
598    }
599
600    async fn create_legacy_session(
601        &self,
602    ) -> WebmcpResult<(
603        String,
604        mpsc::Receiver<ClientJsonRpcMessage>,
605        mpsc::Receiver<ServerJsonRpcMessage>,
606        mpsc::Sender<ServerJsonRpcMessage>,
607        CancellationToken,
608    )> {
609        self.legacy_sessions.create().await
610    }
611
612    async fn legacy_sender(&self, session_id: &str) -> Option<mpsc::Sender<ClientJsonRpcMessage>> {
613        self.legacy_sessions.sender(session_id).await
614    }
615
616    fn authentication_failure(&self, request: &Request<Body>, metadata: bool) -> Option<Response> {
617        if !host_is_allowed(request.headers(), &self.config.public_url) {
618            return Some((StatusCode::FORBIDDEN, "MCP Host header is not allowed").into_response());
619        }
620        match request.headers().get(ORIGIN) {
621            None => {}
622            Some(origin) => {
623                let Ok(origin) = origin.to_str() else {
624                    return Some((StatusCode::BAD_REQUEST, "invalid MCP Origin header").into_response());
625                };
626                if !self.config.allowed_origins.iter().any(|allowed| allowed == origin) {
627                    return Some((StatusCode::FORBIDDEN, "MCP Origin is not allowed").into_response());
628                }
629            }
630        }
631        if metadata {
632            return None;
633        }
634        let authorized = request
635            .headers()
636            .get(AUTHORIZATION)
637            .and_then(|value| value.to_str().ok())
638            .and_then(|value| value.split_once(' '))
639            .is_some_and(|(scheme, token)| {
640                scheme.eq_ignore_ascii_case("Bearer") && token == self.config.proxy_bearer_token()
641            });
642        if authorized {
643            None
644        } else {
645            Some(self.unauthorized_response())
646        }
647    }
648
649    fn unauthorized_response(&self) -> Response {
650        let metadata_url = self.config.metadata_url().to_string();
651        let challenge = format!("Bearer resource_metadata=\"{}\"", quote_header_value(&metadata_url));
652        let mut response = (StatusCode::UNAUTHORIZED, "MCP authentication required").into_response();
653        if let Ok(value) = HeaderValue::from_str(&challenge) {
654            let _ = response.headers_mut().insert(WWW_AUTHENTICATE, value);
655        }
656        response
657    }
658}
659
660async fn authenticate_request(
661    State(endpoint): State<Arc<RemoteMcpEndpoint>>,
662    mut request: Request,
663    next: Next,
664) -> Response {
665    let metadata = request.uri().path() == PROTECTED_RESOURCE_METADATA_PATH;
666    if let Some(response) = endpoint.authentication_failure(&request, metadata) {
667        return response;
668    }
669    let _ = request.headers_mut().remove(AUTHORIZATION);
670    let _ = request.headers_mut().remove(PROXY_AUTHORIZATION);
671    next.run(request).await
672}
673
674async fn protected_resource_metadata(State(endpoint): State<Arc<RemoteMcpEndpoint>>) -> Response {
675    let body = serde_json::json!({
676        "resource": endpoint.config.public_url.as_str(),
677        "authorization_servers": [endpoint.config.authorization_server.as_str()],
678        "bearer_methods_supported": ["header"],
679    });
680    let mut response = AxumJson(body).into_response();
681    let _ = response
682        .headers_mut()
683        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
684    response
685}
686
687async fn legacy_sse_handler(State(endpoint): State<Arc<RemoteMcpEndpoint>>, headers: HeaderMap) -> Response {
688    let accepts_sse = headers.get(ACCEPT).and_then(|value| value.to_str().ok()).is_some_and(|value| {
689        value
690            .split(',')
691            .any(|part| part.trim().eq_ignore_ascii_case("text/event-stream"))
692    });
693    if !accepts_sse {
694        return (StatusCode::NOT_ACCEPTABLE, "legacy MCP SSE requires Accept: text/event-stream").into_response();
695    }
696    let (session_id, input_receiver, output_receiver, output_sender, cancellation) =
697        match endpoint.create_legacy_session().await {
698            Ok(session) => session,
699            Err(WebmcpError::LimitExceeded) => {
700                return (StatusCode::TOO_MANY_REQUESTS, "MCP session limit reached").into_response();
701            }
702            Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "MCP session creation failed").into_response(),
703        };
704    spawn_legacy_server(endpoint.handler.clone(), input_receiver, output_sender, cancellation.clone());
705    let endpoint_event = Event::default().event("endpoint").data(format!("/messages/{session_id}"));
706    // Cancelling the token makes the session's expiry loop remove it; no
707    // async work is spawned from Drop.
708    let guard = cancellation.drop_guard();
709    let message_stream = legacy_event_stream(output_receiver, guard, endpoint_event);
710    Sse::new(message_stream)
711        .keep_alive(KeepAlive::new().interval(LEGACY_KEEP_ALIVE).text("keep-alive"))
712        .into_response()
713}
714
715async fn legacy_message_handler(
716    State(endpoint): State<Arc<RemoteMcpEndpoint>>,
717    Path(session_id): Path<String>,
718    request: Request,
719) -> Response {
720    if request.method() != Method::POST {
721        return (StatusCode::METHOD_NOT_ALLOWED, "MCP messages require POST").into_response();
722    }
723    let content_type = request
724        .headers()
725        .get(CONTENT_TYPE)
726        .and_then(|value| value.to_str().ok())
727        .and_then(|value| value.split(';').next())
728        .is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json"));
729    if !content_type {
730        return (StatusCode::UNSUPPORTED_MEDIA_TYPE, "MCP messages require application/json").into_response();
731    }
732    let body = match body::to_bytes(request.into_body(), endpoint.config.max_request_body_bytes).await {
733        Ok(body) => body,
734        Err(_) => return (StatusCode::PAYLOAD_TOO_LARGE, "MCP request body is too large").into_response(),
735    };
736    let message = match serde_json::from_slice::<ClientJsonRpcMessage>(&body) {
737        Ok(message) => message,
738        Err(_) => return (StatusCode::BAD_REQUEST, "invalid MCP JSON-RPC message").into_response(),
739    };
740    let Some(sender) = endpoint.legacy_sender(&session_id).await else {
741        return (StatusCode::NOT_FOUND, "MCP session is unknown or expired").into_response();
742    };
743    match sender.try_send(message) {
744        Ok(()) => StatusCode::ACCEPTED.into_response(),
745        Err(mpsc::error::TrySendError::Full(_)) => {
746            (StatusCode::TOO_MANY_REQUESTS, "MCP session input queue is full").into_response()
747        }
748        Err(mpsc::error::TrySendError::Closed(_)) => (StatusCode::NOT_FOUND, "MCP session is closed").into_response(),
749    }
750}
751
752fn allowed_hosts(public_url: &Url) -> Vec<String> {
753    let mut hosts = vec!["localhost".to_string(), "127.0.0.1".to_string(), "::1".to_string()];
754    if let Some(host) = public_url.host_str()
755        && !hosts.iter().any(|allowed| allowed.eq_ignore_ascii_case(host))
756    {
757        hosts.push(host.to_string());
758    }
759    hosts
760}
761
762fn host_is_allowed(headers: &HeaderMap, public_url: &Url) -> bool {
763    let Some(value) = headers.get(HOST).and_then(|value| value.to_str().ok()) else {
764        return false;
765    };
766    let Ok(authority) = http::uri::Authority::try_from(value) else {
767        return false;
768    };
769    let host = authority.host().trim_matches(['[', ']']).to_ascii_lowercase();
770    let port = authority.port_u16();
771    if matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1") {
772        return true;
773    }
774    let Some(public_host) = public_url.host_str() else {
775        return false;
776    };
777    host == public_host.to_ascii_lowercase() && public_url.port().is_none_or(|expected| port == Some(expected))
778}
779
780fn quote_header_value(value: &str) -> String {
781    value.replace('\\', "\\\\").replace('"', "\\\"")
782}
783
784fn legacy_event_stream(
785    receiver: mpsc::Receiver<ServerJsonRpcMessage>,
786    guard: DropGuard,
787    endpoint_event: Event,
788) -> impl Stream<Item = Result<Event, Infallible>> + Send + 'static {
789    let first = stream::once(async move { Ok(endpoint_event) });
790    let messages = stream::unfold((receiver, guard), |(mut receiver, guard)| async move {
791        let message = receiver.recv().await?;
792        let data = serde_json::to_string(&message).unwrap_or_else(|_| "{}".to_string());
793        let event = Event::default().event("message").data(data);
794        Some((Ok(event), (receiver, guard)))
795    });
796    first.chain(messages)
797}
798
799#[derive(Clone)]
800struct LegacySessionStore {
801    sessions: Arc<Mutex<BTreeMap<String, LegacySession>>>,
802    ttl: Duration,
803}
804
805struct LegacySession {
806    sender: mpsc::Sender<ClientJsonRpcMessage>,
807    cancellation: CancellationToken,
808    expires_at: Instant,
809}
810
811impl LegacySessionStore {
812    fn new(ttl: Duration) -> Self {
813        Self {
814            sessions: Arc::new(Mutex::new(BTreeMap::new())),
815            ttl,
816        }
817    }
818
819    async fn create(
820        &self,
821    ) -> WebmcpResult<(
822        String,
823        mpsc::Receiver<ClientJsonRpcMessage>,
824        mpsc::Receiver<ServerJsonRpcMessage>,
825        mpsc::Sender<ServerJsonRpcMessage>,
826        CancellationToken,
827    )> {
828        let now = Instant::now();
829        let mut sessions = self.sessions.lock().await;
830        sessions.retain(|_, session| session.expires_at > now && !session.cancellation.is_cancelled());
831        if sessions.len() >= MAX_LEGACY_SESSIONS {
832            return Err(WebmcpError::LimitExceeded);
833        }
834        let session_id = Uuid::new_v4().simple().to_string();
835        let (input_sender, input_receiver) = mpsc::channel(LEGACY_INPUT_QUEUE_CAPACITY);
836        let (output_sender, output_receiver) = mpsc::channel(LEGACY_INPUT_QUEUE_CAPACITY);
837        let cancellation = CancellationToken::new();
838        let _ = sessions.insert(
839            session_id.clone(),
840            LegacySession {
841                sender: input_sender,
842                cancellation: cancellation.clone(),
843                expires_at: now + self.ttl,
844            },
845        );
846        drop(sessions);
847
848        let expiration_store = self.clone();
849        let expiration_id = session_id.clone();
850        let expiration_token = cancellation.clone();
851        // Deliberately detached: bounded by the session TTL and terminated by
852        // `cancellation` (dropped with the session); it owns removing the
853        // session from the store, so its outcome cannot be silently lost.
854        drop(tokio::spawn(async move {
855            expiration_store.expire_when_idle(expiration_id, expiration_token).await;
856        }));
857
858        Ok((session_id, input_receiver, output_receiver, output_sender, cancellation))
859    }
860
861    async fn sender(&self, session_id: &str) -> Option<mpsc::Sender<ClientJsonRpcMessage>> {
862        let now = Instant::now();
863        let mut sessions = self.sessions.lock().await;
864        let session = sessions.get_mut(session_id)?;
865        if session.expires_at <= now || session.cancellation.is_cancelled() {
866            let session = sessions.remove(session_id)?;
867            session.cancellation.cancel();
868            return None;
869        }
870        session.expires_at = now + self.ttl;
871        Some(session.sender.clone())
872    }
873
874    async fn remove(&self, session_id: &str) {
875        if let Some(session) = self.sessions.lock().await.remove(session_id) {
876            session.cancellation.cancel();
877        }
878    }
879
880    async fn expire_when_idle(&self, session_id: String, cancellation: CancellationToken) {
881        loop {
882            let Some(remaining) = self.remaining(&session_id).await else {
883                return;
884            };
885            tokio::select! {
886                _ = sleep(remaining) => {}
887                // The token is cancelled by the stream's DropGuard or the
888                // server task ending; this loop owns removing the session.
889                _ = cancellation.cancelled() => {
890                    self.remove(&session_id).await;
891                    return;
892                }
893            }
894            let expired = self
895                .sessions
896                .lock()
897                .await
898                .get(&session_id)
899                .is_some_and(|session| session.expires_at <= Instant::now());
900            if expired {
901                self.remove(&session_id).await;
902                return;
903            }
904        }
905    }
906
907    async fn remaining(&self, session_id: &str) -> Option<Duration> {
908        self.sessions
909            .lock()
910            .await
911            .get(session_id)
912            .map(|session| session.expires_at.saturating_duration_since(Instant::now()))
913    }
914}
915
916#[derive(Debug)]
917struct LegacyTransportError;
918
919impl Display for LegacyTransportError {
920    fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
921        formatter.write_str("legacy MCP SSE output channel is closed")
922    }
923}
924
925impl Error for LegacyTransportError {}
926
927struct LegacyMessageSink {
928    sender: mpsc::Sender<ServerJsonRpcMessage>,
929}
930
931impl Sink<ServerJsonRpcMessage> for LegacyMessageSink {
932    type Error = LegacyTransportError;
933
934    fn poll_ready(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
935        if self.get_mut().sender.is_closed() {
936            Poll::Ready(Err(LegacyTransportError))
937        } else {
938            Poll::Ready(Ok(()))
939        }
940    }
941
942    fn start_send(self: Pin<&mut Self>, item: ServerJsonRpcMessage) -> Result<(), Self::Error> {
943        self.get_mut().sender.try_send(item).map_err(|_error| LegacyTransportError)
944    }
945
946    fn poll_flush(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
947        Poll::Ready(Ok(()))
948    }
949
950    fn poll_close(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
951        Poll::Ready(Ok(()))
952    }
953}
954
955fn spawn_legacy_server(
956    handler: RemoteMcpHandler,
957    input_receiver: mpsc::Receiver<ClientJsonRpcMessage>,
958    output_sender: mpsc::Sender<ServerJsonRpcMessage>,
959    cancellation: CancellationToken,
960) {
961    drop(tokio::spawn(async move {
962        let transport =
963            SinkStreamTransport::new(LegacyMessageSink { sender: output_sender }, ReceiverStream::new(input_receiver));
964        if let Ok(service) = handler.serve_with_ct(transport, cancellation.clone()).await {
965            drop(service.waiting().await);
966        }
967        cancellation.cancel();
968    }));
969}
970
971#[cfg(test)]
972mod tests;