1use 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#[derive(Clone)]
68pub struct RemoteMcpServerConfig {
69 pub public_url: Url,
71 pub authorization_server: Url,
73 proxy_bearer_token: Arc<str>,
74 pub citation_url_prefix: Option<Url>,
76 pub allowed_origins: Vec<String>,
78 pub max_results: usize,
80 pub max_scan_files: usize,
82 pub max_scan_bytes: usize,
84 pub max_request_body_bytes: usize,
86 pub session_ttl: Duration,
88 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 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 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 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 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 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#[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 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 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 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 pub fn tool_definitions(&self) -> Vec<Tool> {
327 self.tool_router.list_all()
328 }
329
330 #[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 #[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 #[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 #[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 #[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
535pub 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 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 pub fn config(&self) -> &RemoteMcpServerConfig {
581 &self.config
582 }
583
584 pub fn tool_definitions(&self) -> Vec<Tool> {
586 self.handler.tool_definitions()
587 }
588
589 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 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 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 _ = 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;