1use crate::error::{Result, WebmcpError};
2use crate::event_hub::{EventHubConfig, EventHubSubscription, MAX_EVENT_BYTES, WebmcpEventHub};
3use crate::pairing::{PairingDisplay, PairingManager};
4use crate::protocol::{
5 BridgeEventMessage, BridgeRequest, BridgeResponse, BridgeSettings, PROTOCOL_VERSION, PairPayload, StatusPayload,
6 is_valid_request_id, response_request_id,
7};
8use crate::remote_mcp::{RemoteMcpEndpoint, RemoteMcpServerConfig};
9use crate::runtime::RuntimeAdapter;
10use axum::Router;
11use axum::extract::{State, WebSocketUpgrade, ws};
12use axum::http::{HeaderMap, StatusCode, header::ORIGIN};
13use axum::response::{IntoResponse, Response};
14use futures::{StreamExt, future::BoxFuture};
15use serde_json::Value;
16use std::net::IpAddr;
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19use tokio::net::TcpListener;
20use tokio::sync::{OwnedSemaphorePermit, Semaphore, mpsc, oneshot};
21use vtcode_commons::validation::is_valid_origin;
22
23const MAX_PAIRED_CONNECTIONS: usize = 64;
24const MAX_CONNECTIONS: usize = 128;
25const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
26const EVENT_ENVELOPE_OVERHEAD: usize = 256;
27const MIN_FRAME_BYTES: usize = 1024;
28const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
29const MAX_MUTATION_QUEUE: usize = 64;
30const SESSION_LEASE_CHECK_INTERVAL: Duration = Duration::from_millis(250);
31
32#[derive(Debug, Clone)]
34pub struct WebmcpServerConfig {
35 pub host: String,
37 pub port: u16,
39 pub allowed_origins: Vec<String>,
41 pub pairing_ttl_secs: u64,
43 pub max_frame_bytes: usize,
45 pub max_in_flight_requests: usize,
47 pub allow_remote: bool,
49 pub public_url: Option<String>,
51 pub remote_mcp: Option<RemoteMcpServerConfig>,
53 pub event_hub: EventHubConfig,
55 pub request_timeout: Duration,
57}
58
59impl Default for WebmcpServerConfig {
60 fn default() -> Self {
61 Self {
62 host: "127.0.0.1".to_string(),
63 port: 0,
64 allowed_origins: Vec::new(),
65 pairing_ttl_secs: 300,
66 max_frame_bytes: 1024 * 1024,
67 max_in_flight_requests: 8,
68 allow_remote: false,
69 public_url: None,
70 remote_mcp: None,
71 event_hub: EventHubConfig::default(),
72 request_timeout: Duration::from_secs(30),
73 }
74 }
75}
76
77struct ServerState {
78 adapter: Arc<dyn RuntimeAdapter>,
79 pairing: PairingManager,
80 event_hub: WebmcpEventHub,
81 dispatch: Arc<DispatchState>,
82 mutation_supervisor: MutationSupervisor,
83 paired_connections: Arc<Semaphore>,
84 connections: Arc<Semaphore>,
85 max_frame_bytes: usize,
86 request_timeout: Duration,
87 remote_mcp: Option<Arc<RemoteMcpEndpoint>>,
88}
89
90struct DispatchState {
91 adapter: Arc<dyn RuntimeAdapter>,
92 pairing: PairingManager,
93 event_hub: WebmcpEventHub,
94 settings: BridgeSettings,
95 in_flight: Arc<Semaphore>,
96 request_timeout: Duration,
97}
98
99struct MutationJob {
100 dispatch: Arc<DispatchState>,
101 origin: String,
102 token: String,
103 request: BridgeRequest,
104 result: oneshot::Sender<Result<Value>>,
105}
106
107#[derive(Clone, Default)]
108struct MutationSupervisor {
109 sender: Arc<tokio::sync::Mutex<Option<mpsc::Sender<MutationJob>>>>,
110}
111
112impl MutationSupervisor {
113 async fn submit(
114 &self,
115 dispatch: Arc<DispatchState>,
116 origin: String,
117 token: String,
118 request: BridgeRequest,
119 ) -> Result<Value> {
120 let sender = {
121 let mut sender_slot = self.sender.lock().await;
122 if let Some(sender) = sender_slot.as_ref() {
123 sender.clone()
124 } else {
125 let (sender, receiver) = mpsc::channel(MAX_MUTATION_QUEUE);
126 drop(tokio::spawn(run_mutation_supervisor(receiver)));
127 *sender_slot = Some(sender.clone());
128 sender
129 }
130 };
131 let (result, receiver) = oneshot::channel();
132 sender
133 .try_send(MutationJob { dispatch, origin, token, request, result })
134 .map_err(|error| match error {
135 mpsc::error::TrySendError::Full(_) => WebmcpError::LimitExceeded,
136 mpsc::error::TrySendError::Closed(_) => {
137 WebmcpError::Adapter("WebMCP mutation supervisor is closed".to_string())
138 }
139 })?;
140 receiver
141 .await
142 .map_err(|_error| WebmcpError::Adapter("WebMCP mutation supervisor stopped".to_string()))?
143 }
144}
145
146async fn run_mutation_supervisor(mut receiver: mpsc::Receiver<MutationJob>) {
147 while let Some(job) = receiver.recv().await {
148 let result = dispatch_request(job.dispatch, job.origin, job.token, job.request).await;
149 drop(job.result.send(result));
150 }
151}
152
153#[derive(Clone)]
155pub struct WebmcpServer {
156 state: Arc<ServerState>,
157 config: WebmcpServerConfig,
158}
159
160impl std::fmt::Debug for WebmcpServer {
161 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 formatter
163 .debug_struct("WebmcpServer")
164 .field("host", &self.config.host)
165 .field("port", &self.config.port)
166 .field("allowed_origins", &self.config.allowed_origins)
167 .finish_non_exhaustive()
168 }
169}
170
171impl WebmcpServer {
172 pub fn new(adapter: Arc<dyn RuntimeAdapter>, config: WebmcpServerConfig) -> Result<Self> {
174 validate_config(&config)?;
175 let pairing = PairingManager::new(&config.allowed_origins, Duration::from_secs(config.pairing_ttl_secs))?;
176 let remote_mcp = config
177 .remote_mcp
178 .clone()
179 .map(|remote_config| RemoteMcpEndpoint::new(Arc::clone(&adapter), remote_config).map(Arc::new))
180 .transpose()?;
181 let event_limit = config
182 .max_frame_bytes
183 .saturating_sub(EVENT_ENVELOPE_OVERHEAD)
184 .min(MAX_EVENT_BYTES);
185 let event_hub = WebmcpEventHub::new_with_max_event_bytes(config.event_hub, event_limit)?;
186 let in_flight = Arc::new(Semaphore::new(config.max_in_flight_requests));
187 let settings = BridgeSettings {
188 host: config.host.clone(),
189 port: config.port,
190 pairing_ttl_secs: config.pairing_ttl_secs,
191 max_frame_bytes: config.max_frame_bytes,
192 max_in_flight_requests: config.max_in_flight_requests,
193 remote_enabled: config.allow_remote,
194 };
195 let dispatch = Arc::new(DispatchState {
196 adapter: Arc::clone(&adapter),
197 pairing: pairing.clone(),
198 event_hub: event_hub.clone(),
199 settings,
200 in_flight: Arc::clone(&in_flight),
201 request_timeout: config.request_timeout,
202 });
203 Ok(Self {
204 state: Arc::new(ServerState {
205 adapter,
206 pairing,
207 event_hub,
208 dispatch,
209 mutation_supervisor: MutationSupervisor::default(),
210 paired_connections: Arc::new(Semaphore::new(MAX_PAIRED_CONNECTIONS)),
211 connections: Arc::new(Semaphore::new(MAX_CONNECTIONS)),
212 max_frame_bytes: config.max_frame_bytes,
213 request_timeout: config.request_timeout,
214 remote_mcp,
215 }),
216 config,
217 })
218 }
219
220 pub fn begin_pairing(&self) -> PairingDisplay {
222 self.state.pairing.begin_pairing()
223 }
224
225 pub fn begin_pairing_for_origin(&self, origin: impl Into<String>) -> Result<PairingDisplay> {
227 self.state.pairing.begin_pairing_for_origin(origin)
228 }
229
230 pub fn replace_pairing_for_origin(&self, origin: impl Into<String>) -> Result<PairingDisplay> {
232 self.state.pairing.replace_pairing_for_origin(origin)
233 }
234
235 pub fn revoke_all_pairings(&self) {
237 self.state.pairing.revoke_all();
238 }
239
240 pub fn event_hub(&self) -> WebmcpEventHub {
242 self.state.event_hub.clone()
243 }
244
245 pub fn router(&self) -> Router {
247 let mut router = Router::new().route("/webmcp", axum::routing::get(websocket_handler));
248 if let Some(remote_mcp) = self.state.remote_mcp.as_ref() {
249 let remote_routes = remote_mcp
250 .routes()
251 .with_state(Arc::clone(remote_mcp))
252 .with_state(self.state.clone());
253 router = router.merge(remote_routes);
254 }
255 router.with_state(self.state.clone())
256 }
257
258 pub async fn bind(&self) -> Result<TcpListener> {
260 TcpListener::bind((self.config.host.as_str(), self.config.port))
261 .await
262 .map_err(WebmcpError::Io)
263 }
264
265 pub async fn serve_listener(&self, listener: TcpListener) -> Result<()> {
267 axum::serve(listener, self.router())
268 .await
269 .map_err(|error| WebmcpError::Adapter(format!("WebMCP listener failed: {error}")))
270 }
271
272 pub async fn serve(&self) -> Result<()> {
274 let listener = self.bind().await?;
275 self.serve_listener(listener).await
276 }
277}
278
279async fn websocket_handler(
280 State(state): State<Arc<ServerState>>,
281 headers: HeaderMap,
282 upgrade: WebSocketUpgrade,
283) -> Response {
284 let Some(origin) = origin_from_headers(&headers) else {
285 return (StatusCode::FORBIDDEN, "WebMCP requires an Origin header").into_response();
286 };
287 if !state.pairing.is_origin_allowed(origin) {
288 return (StatusCode::FORBIDDEN, "WebMCP origin is not allowed").into_response();
289 }
290 let connection_permit = match state.connections.clone().try_acquire_owned() {
291 Ok(permit) => permit,
292 Err(_error) => return (StatusCode::TOO_MANY_REQUESTS, "WebMCP connection limit reached").into_response(),
293 };
294 let origin = origin.to_string();
295 upgrade
296 .max_message_size(state.max_frame_bytes)
297 .max_frame_size(state.max_frame_bytes)
298 .on_upgrade(move |socket| run_socket(socket, state, origin, connection_permit))
299 .into_response()
300}
301
302fn origin_from_headers(headers: &HeaderMap) -> Option<&str> {
303 headers
304 .get(ORIGIN)
305 .and_then(|value| value.to_str().ok())
306 .filter(|origin| !origin.is_empty())
307}
308
309async fn run_socket(
310 mut socket: ws::WebSocket,
311 state: Arc<ServerState>,
312 origin: String,
313 _connection_permit: OwnedSemaphorePermit,
314) {
315 let handshake_deadline = Instant::now() + HANDSHAKE_TIMEOUT;
316 loop {
317 let remaining = handshake_deadline.saturating_duration_since(Instant::now());
318 if remaining.is_zero() {
319 return;
320 }
321 let message = match tokio::time::timeout(remaining, socket.next()).await {
322 Ok(Some(message)) => message,
323 Ok(None) | Err(_) => return,
324 };
325 let Ok(message) = message else { return };
326 match message {
327 ws::Message::Text(text) => {
328 if text.len() > state.max_frame_bytes {
329 drop(
330 send_response(
331 &mut socket,
332 BridgeResponse::failure("unknown", "frame_too_large", "request exceeds the frame limit"),
333 state.max_frame_bytes,
334 )
335 .await,
336 );
337 return;
338 }
339 match serde_json::from_slice::<BridgeRequest>(text.as_bytes()) {
340 Ok(BridgeRequest::Pair {
341 request_id,
342 code,
343 resume_token,
344 origin: claimed_origin,
345 after_sequence,
346 }) => {
347 if !is_valid_request_id(&request_id) {
348 drop(
349 send_response(
350 &mut socket,
351 invalid_request_id_response(&request_id),
352 state.max_frame_bytes,
353 )
354 .await,
355 );
356 continue;
357 }
358 if claimed_origin.as_deref().is_some_and(|claimed| claimed != origin) {
359 drop(
360 send_response(
361 &mut socket,
362 BridgeResponse::failure(
363 &request_id,
364 "origin_mismatch",
365 "request origin does not match the WebSocket origin",
366 ),
367 state.max_frame_bytes,
368 )
369 .await,
370 );
371 return;
372 }
373 let mut subscription = match state.event_hub.subscribe(after_sequence) {
374 Ok(subscription) => subscription,
375 Err(error) => {
376 drop(
377 send_response(
378 &mut socket,
379 response_for_error(&request_id, error),
380 state.max_frame_bytes,
381 )
382 .await,
383 );
384 return;
385 }
386 };
387 let connection_permit = match state.paired_connections.clone().try_acquire_owned() {
388 Ok(permit) => permit,
389 Err(_error) => {
390 drop(
391 send_response(
392 &mut socket,
393 BridgeResponse::failure(
394 &request_id,
395 "connection_limit",
396 "the WebMCP server has reached its paired connection limit",
397 ),
398 state.max_frame_bytes,
399 )
400 .await,
401 );
402 return;
403 }
404 };
405 let session = match resume_token {
406 Some(token) => state.pairing.resume(&token, &origin),
407 None => state.pairing.pair(&code, &origin),
408 };
409 match session {
410 Ok(session) => {
411 let response = BridgeResponse::success(
412 request_id,
413 PairPayload {
414 token: session.token().to_string(),
415 protocol_version: PROTOCOL_VERSION,
416 expires_in_secs: session.expires_in().as_secs().max(1),
417 },
418 );
419 if send_response(&mut socket, response, state.max_frame_bytes).await.is_err() {
420 return;
421 }
422 for event in subscription.replay() {
423 if send_event(&mut socket, event.sequence, &event.event, state.max_frame_bytes)
424 .await
425 .is_err()
426 {
427 return;
428 }
429 }
430 run_paired_socket(
431 socket,
432 state,
433 origin,
434 session.token().to_string(),
435 &mut subscription,
436 connection_permit,
437 _connection_permit,
438 )
439 .await;
440 return;
441 }
442 Err(error) => {
443 drop(
444 send_response(
445 &mut socket,
446 response_for_error(&request_id, error),
447 state.max_frame_bytes,
448 )
449 .await,
450 );
451 }
452 }
453 }
454 Ok(request) => {
455 let request_id = request.request_id().to_string();
456 let response = if is_valid_request_id(&request_id) {
457 response_for_error(&request_id, WebmcpError::Unauthorized)
458 } else {
459 invalid_request_id_response(&request_id)
460 };
461 drop(send_response(&mut socket, response, state.max_frame_bytes).await);
462 }
463 Err(error) => {
464 drop(
465 send_response(
466 &mut socket,
467 BridgeResponse::failure("unknown", "malformed_request", error.to_string()),
468 state.max_frame_bytes,
469 )
470 .await,
471 );
472 }
473 }
474 }
475 ws::Message::Binary(_) => {
476 drop(
477 send_response(
478 &mut socket,
479 BridgeResponse::failure(
480 "unknown",
481 "binary_not_supported",
482 "WebMCP accepts JSON text frames only",
483 ),
484 state.max_frame_bytes,
485 )
486 .await,
487 );
488 }
489 ws::Message::Ping(payload) => {
490 if send_pong(&mut socket, payload).await.is_err() {
491 return;
492 }
493 }
494 ws::Message::Close(_) => return,
495 ws::Message::Pong(_) => {}
496 }
497 }
498}
499
500enum PairedFrameAction {
501 Request(BridgeRequest),
502 Continue,
503 Close,
504}
505
506async fn handle_paired_frame(
507 socket: &mut ws::WebSocket,
508 message: ws::Message,
509 max_frame_bytes: usize,
510) -> PairedFrameAction {
511 match message {
512 ws::Message::Text(text) => {
513 if text.len() > max_frame_bytes {
514 drop(
515 send_response(
516 socket,
517 BridgeResponse::failure("unknown", "frame_too_large", "request exceeds the frame limit"),
518 max_frame_bytes,
519 )
520 .await,
521 );
522 return PairedFrameAction::Close;
523 }
524 let request = match serde_json::from_slice::<BridgeRequest>(text.as_bytes()) {
525 Ok(request) => request,
526 Err(error) => {
527 if send_response(
528 socket,
529 BridgeResponse::failure("unknown", "malformed_request", error.to_string()),
530 max_frame_bytes,
531 )
532 .await
533 .is_err()
534 {
535 return PairedFrameAction::Close;
536 }
537 return PairedFrameAction::Continue;
538 }
539 };
540 if !is_valid_request_id(request.request_id()) {
541 if send_response(socket, invalid_request_id_response(request.request_id()), max_frame_bytes)
542 .await
543 .is_err()
544 {
545 return PairedFrameAction::Close;
546 }
547 return PairedFrameAction::Continue;
548 }
549 PairedFrameAction::Request(request)
550 }
551 ws::Message::Binary(_) => {
552 if send_response(
553 socket,
554 BridgeResponse::failure("unknown", "binary_not_supported", "WebMCP accepts JSON text frames only"),
555 max_frame_bytes,
556 )
557 .await
558 .is_err()
559 {
560 PairedFrameAction::Close
561 } else {
562 PairedFrameAction::Continue
563 }
564 }
565 ws::Message::Ping(payload) => {
566 if send_pong(socket, payload).await.is_err() {
567 PairedFrameAction::Close
568 } else {
569 PairedFrameAction::Continue
570 }
571 }
572 ws::Message::Close(_) => PairedFrameAction::Close,
573 ws::Message::Pong(_) => PairedFrameAction::Continue,
574 }
575}
576
577async fn run_paired_socket(
578 mut socket: ws::WebSocket,
579 state: Arc<ServerState>,
580 origin: String,
581 token: String,
582 subscription: &mut EventHubSubscription,
583 _connection_permit: OwnedSemaphorePermit,
584 _all_connections_permit: OwnedSemaphorePermit,
585) {
586 let mut expiry_check = tokio::time::interval(SESSION_LEASE_CHECK_INTERVAL);
587 expiry_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
588 loop {
589 tokio::select! {
590 message = socket.next() => {
591 let Some(Ok(message)) = message else { return };
592 match handle_paired_frame(&mut socket, message, state.max_frame_bytes).await {
593 PairedFrameAction::Close => return,
594 PairedFrameAction::Continue => {}
595 PairedFrameAction::Request(request) => {
596 let request_id = request.request_id().to_string();
597 let mutation_request = matches!(
598 &request,
599 BridgeRequest::ApplyProposal { .. } | BridgeRequest::RevertLastChange { .. }
600 );
601 let operation: BoxFuture<'static, Result<Value>> = if mutation_request {
602 let supervisor = state.mutation_supervisor.clone();
603 let dispatch = Arc::clone(&state.dispatch);
604 let origin = origin.clone();
605 let token = token.clone();
606 Box::pin(async move { supervisor.submit(dispatch, origin, token, request).await })
607 } else {
608 Box::pin(dispatch_request(
609 Arc::clone(&state.dispatch),
610 origin.clone(),
611 token.clone(),
612 request,
613 ))
614 };
615 let mut operation = operation;
616 loop {
617 tokio::select! {
618 result = &mut operation => {
619 let response = match result {
620 Ok(payload) => BridgeResponse::success(request_id.clone(), payload),
621 Err(error) => response_for_error(&request_id, error),
622 };
623 if send_response(&mut socket, response, state.max_frame_bytes).await.is_err() { return; }
624 break;
625 }
626 message = socket.next() => {
627 let Some(Ok(message)) = message else { return };
628 match handle_paired_frame(&mut socket, message, state.max_frame_bytes).await {
629 PairedFrameAction::Close => return,
630 PairedFrameAction::Continue => {}
631 PairedFrameAction::Request(request) => {
632 let request_id = request.request_id().to_string();
633 let response = if request.token() != Some(token.as_str()) {
634 response_for_error(&request_id, WebmcpError::Unauthorized)
635 } else if matches!(&request, BridgeRequest::Cancel { .. }) {
636 match dispatch_cancel_request(&state, &origin, &token, request).await {
637 Ok(payload) => BridgeResponse::success(request_id.clone(), payload),
638 Err(error) => response_for_error(&request_id, error),
639 }
640 } else {
641 if state.pairing.refresh(&token, &origin).is_err() { return; }
642 BridgeResponse::failure(&request_id, "request_in_progress", "wait for the active request or cancel it")
643 };
644 if send_response(&mut socket, response, state.max_frame_bytes).await.is_err() { return; }
645 }
646 }
647 }
648 event = subscription.recv() => {
649 let Some(event) = event else {
650 drop(send_response(&mut socket, BridgeResponse::failure("event", "slow_client", "client could not keep up with runtime events"), state.max_frame_bytes).await);
651 return;
652 };
653 if state.pairing.validate(&token, &origin).is_err() { return; }
654 if send_event(&mut socket, event.sequence, &event.event, state.max_frame_bytes).await.is_err() { return; }
655 }
656 _ = expiry_check.tick() => {
657 if state.pairing.refresh(&token, &origin).is_err() { return; }
661 }
662 }
663 }
664 }
665 }
666 }
667 event = subscription.recv() => {
668 let Some(event) = event else {
669 drop(send_response(&mut socket, BridgeResponse::failure("event", "slow_client", "client could not keep up with runtime events"), state.max_frame_bytes).await);
670 return;
671 };
672 if state.pairing.validate(&token, &origin).is_err() { return; }
673 if send_event(&mut socket, event.sequence, &event.event, state.max_frame_bytes).await.is_err() { return; }
674 }
675 _ = expiry_check.tick() => {
676 if state.pairing.validate(&token, &origin).is_err() { return; }
677 }
678 }
679 }
680}
681
682async fn dispatch_request(
683 dispatch: Arc<DispatchState>,
684 origin: String,
685 token: String,
686 request: BridgeRequest,
687) -> Result<Value> {
688 if request.token() != Some(token.as_str()) {
689 return Err(WebmcpError::Unauthorized);
690 }
691 dispatch.pairing.refresh(&token, &origin)?;
692 let _permit = tokio::time::timeout(dispatch.request_timeout, dispatch.in_flight.clone().acquire_owned())
693 .await
694 .map_err(|_error| WebmcpError::Timeout(dispatch.request_timeout))?
695 .map_err(|_error| WebmcpError::Adapter("WebMCP request capacity is closed".to_string()))?;
696 dispatch.pairing.refresh(&token, &origin)?;
700 let mutation_request =
701 matches!(&request, BridgeRequest::ApplyProposal { .. } | BridgeRequest::RevertLastChange { .. });
702 let operation = async {
703 match request {
704 BridgeRequest::Pair { .. } => Err(WebmcpError::Unauthorized),
705 BridgeRequest::ExplanationGet { scope, offset, .. } => {
706 serde_json::to_value(dispatch.adapter.explanation_get(scope, offset).await?).map_err(WebmcpError::from)
707 }
708 BridgeRequest::ExplanationEvidence { reference, offset, .. } => {
709 serde_json::to_value(dispatch.adapter.explanation_evidence(reference, offset).await?)
710 .map_err(WebmcpError::from)
711 }
712 BridgeRequest::ExplanationNavigate { reference, .. } => {
713 Ok(serde_json::json!({"focused": dispatch.adapter.explanation_navigate(reference).await?}))
714 }
715 BridgeRequest::Status { .. } => {
716 let runtime = dispatch.adapter.status().await?;
717 serde_json::to_value(StatusPayload {
718 protocol_version: PROTOCOL_VERSION,
719 connected: runtime.connected,
720 runtime,
721 authenticated_origin: origin,
722 settings: dispatch.settings.clone(),
723 latest_sequence: dispatch.event_hub.latest_sequence(),
724 })
725 .map_err(WebmcpError::Json)
726 }
727 BridgeRequest::ListFiles { .. } => {
728 serde_json::to_value(dispatch.adapter.list_files().await?).map_err(WebmcpError::Json)
729 }
730 BridgeRequest::ReadFile { path, .. } => {
731 serde_json::to_value(dispatch.adapter.read_file(&path).await?).map_err(WebmcpError::Json)
732 }
733 BridgeRequest::ProposeChanges { changes, .. } => {
734 serde_json::to_value(dispatch.adapter.propose_changes(changes).await?).map_err(WebmcpError::Json)
735 }
736 BridgeRequest::ApplyProposal { proposal_id, .. } => {
737 serde_json::to_value(dispatch.adapter.apply_proposal(&proposal_id).await?).map_err(WebmcpError::Json)
738 }
739 BridgeRequest::RunChecks { command, .. } => {
740 serde_json::to_value(dispatch.adapter.run_checks(&command).await?).map_err(WebmcpError::Json)
741 }
742 BridgeRequest::RevertLastChange { change_id, .. } => {
743 serde_json::to_value(dispatch.adapter.revert_last_change(&change_id).await?).map_err(WebmcpError::Json)
744 }
745 BridgeRequest::RequestTurn { prompt, proposal_id, .. } => {
746 serde_json::to_value(dispatch.adapter.request_turn(&prompt, proposal_id.as_deref()).await?)
747 .map_err(WebmcpError::Json)
748 }
749 BridgeRequest::Cancel { target_id, .. } => {
750 let accepted = dispatch.adapter.cancel(&target_id).await?;
751 Ok(serde_json::json!({ "cancelled": target_id, "accepted": accepted }))
752 }
753 }
754 };
755 if mutation_request {
756 operation.await
760 } else {
761 tokio::time::timeout(dispatch.request_timeout, operation)
762 .await
763 .map_err(|_error| WebmcpError::Timeout(dispatch.request_timeout))?
764 }
765}
766
767async fn dispatch_cancel_request(
768 state: &Arc<ServerState>,
769 origin: &str,
770 token: &str,
771 request: BridgeRequest,
772) -> Result<Value> {
773 if request.token() != Some(token) {
774 return Err(WebmcpError::Unauthorized);
775 }
776 state.pairing.refresh(token, origin)?;
777 let BridgeRequest::Cancel { target_id, .. } = request else {
778 return Err(WebmcpError::InvalidRequest(
779 "only cancellation requests are accepted while a request is running".to_string(),
780 ));
781 };
782 let accepted = tokio::time::timeout(state.request_timeout, state.adapter.cancel(&target_id))
783 .await
784 .map_err(|_error| WebmcpError::Timeout(state.request_timeout))??;
785 Ok(serde_json::json!({ "cancelled": target_id, "accepted": accepted }))
786}
787
788async fn send_response(socket: &mut ws::WebSocket, response: BridgeResponse, max_frame_bytes: usize) -> Result<()> {
789 let serialized = serde_json::to_string(&response)?;
790 let serialized = if serialized.len() > max_frame_bytes {
791 serde_json::to_string(&BridgeResponse::failure(
792 response.request_id,
793 "limit_exceeded",
794 "WebMCP response exceeds the configured frame limit",
795 ))?
796 } else {
797 serialized
798 };
799 if serialized.len() > max_frame_bytes {
800 return Err(WebmcpError::LimitExceeded);
801 }
802 tokio::time::timeout(WRITE_TIMEOUT, socket.send(ws::Message::Text(serialized.into())))
803 .await
804 .map_err(|_error| WebmcpError::Timeout(WRITE_TIMEOUT))?
805 .map_err(|error| WebmcpError::Adapter(error.to_string()))
806}
807
808async fn send_pong(socket: &mut ws::WebSocket, payload: axum::body::Bytes) -> Result<()> {
809 tokio::time::timeout(WRITE_TIMEOUT, socket.send(ws::Message::Pong(payload)))
810 .await
811 .map_err(|_error| WebmcpError::Timeout(WRITE_TIMEOUT))?
812 .map_err(|error| WebmcpError::Adapter(error.to_string()))
813}
814
815async fn send_event(
816 socket: &mut ws::WebSocket,
817 sequence: u64,
818 event: &vtcode_exec_events::VersionedThreadEvent,
819 max_frame_bytes: usize,
820) -> Result<()> {
821 let message = BridgeEventMessage { kind: "event", sequence, event: event.clone() };
822 let serialized = serde_json::to_string(&message)?;
823 if serialized.len() > max_frame_bytes {
824 return Err(WebmcpError::LimitExceeded);
825 }
826 tokio::time::timeout(WRITE_TIMEOUT, socket.send(ws::Message::Text(serialized.into())))
827 .await
828 .map_err(|_error| WebmcpError::Timeout(WRITE_TIMEOUT))?
829 .map_err(|error| WebmcpError::Adapter(error.to_string()))
830}
831
832fn response_for_error(request_id: &str, error: WebmcpError) -> BridgeResponse {
833 let (code, message) = match &error {
834 WebmcpError::OriginRejected(_) => ("origin_rejected", "browser origin is not allowed".to_string()),
835 WebmcpError::PairingExpired => ("pairing_expired", error.to_string()),
836 WebmcpError::PairingUsed => ("pairing_used", error.to_string()),
837 WebmcpError::Unauthorized => ("unauthorized", error.to_string()),
838 WebmcpError::LimitExceeded => ("limit_exceeded", error.to_string()),
839 WebmcpError::PathRejected(_) => ("path_rejected", error.to_string()),
840 WebmcpError::Conflict { .. } => ("conflict", error.to_string()),
841 WebmcpError::ProposalNotFound => ("proposal_not_found", error.to_string()),
842 WebmcpError::ApprovalRequired => ("approval_required", error.to_string()),
843 WebmcpError::Unsupported(_) => ("unsupported", error.to_string()),
844 WebmcpError::ChangeNotFound => ("change_not_found", error.to_string()),
845 WebmcpError::PartialApply => ("partial_apply", error.to_string()),
846 WebmcpError::SequenceGap { .. } => ("sequence_gap", error.to_string()),
847 WebmcpError::SlowClient => ("slow_client", error.to_string()),
848 WebmcpError::Timeout(_) => ("timeout", error.to_string()),
849 WebmcpError::InvalidRequest(_) | WebmcpError::Json(_) => ("invalid_request", error.to_string()),
850 WebmcpError::Io(_) | WebmcpError::Adapter(_) => {
851 ("runtime_error", "WebMCP runtime operation failed".to_string())
852 }
853 };
854 BridgeResponse::failure(response_request_id(request_id), code, message)
855}
856
857fn invalid_request_id_response(request_id: &str) -> BridgeResponse {
858 BridgeResponse::failure(
859 response_request_id(request_id),
860 "invalid_request",
861 "request_id must be between 1 and 256 UTF-8 bytes",
862 )
863}
864
865fn validate_config(config: &WebmcpServerConfig) -> Result<()> {
866 if config.host.trim().is_empty() || config.max_in_flight_requests == 0 {
867 return Err(WebmcpError::InvalidRequest("WebMCP host and limits must be non-empty".to_string()));
868 }
869 if config.max_frame_bytes < MIN_FRAME_BYTES
870 || config.max_frame_bytes > 16 * 1024 * 1024
871 || config.max_in_flight_requests > 64
872 {
873 return Err(WebmcpError::LimitExceeded);
874 }
875 if (config.allowed_origins.is_empty() && config.remote_mcp.is_none())
876 || config.allowed_origins.iter().any(|origin| !is_valid_origin(origin))
877 {
878 return Err(WebmcpError::InvalidRequest("WebMCP requires an explicit origin allowlist".to_string()));
879 }
880 let address = config
881 .host
882 .parse::<IpAddr>()
883 .map_err(|_error| WebmcpError::InvalidRequest("WebMCP host must be a literal IP address".to_string()))?;
884 if !address.is_loopback() {
885 return Err(WebmcpError::InvalidRequest(
886 "WebMCP only binds loopback; place a TLS-terminating reverse proxy in front of it for remote access"
887 .to_string(),
888 ));
889 }
890 match (config.allow_remote, config.public_url.as_deref()) {
891 (false, Some(_)) => {
892 return Err(WebmcpError::InvalidRequest("--public-url requires remote WebMCP mode".to_string()));
893 }
894 (true, None) => {
895 return Err(WebmcpError::InvalidRequest("remote WebMCP mode requires a wss:// public URL".to_string()));
896 }
897 (true, Some(url)) if !is_valid_public_url(url) => {
898 return Err(WebmcpError::InvalidRequest(
899 "remote WebMCP mode requires a valid wss:// public URL".to_string(),
900 ));
901 }
902 (false, None) => {}
903 (true, Some(_)) => {}
904 }
905 if config.request_timeout.is_zero() {
906 return Err(WebmcpError::InvalidRequest("WebMCP request timeout must be positive".to_string()));
907 }
908 if let Some(remote_mcp) = config.remote_mcp.as_ref() {
909 remote_mcp.validate()?;
910 }
911 Ok(())
912}
913
914fn is_valid_public_url(url: &str) -> bool {
915 let Ok(parsed) = url::Url::parse(url) else {
916 return false;
917 };
918 url == url.trim()
919 && !url.chars().any(char::is_whitespace)
920 && parsed.scheme() == "wss"
921 && parsed.host_str().is_some_and(|host| !host.is_empty())
922 && parsed.username().is_empty()
923 && parsed.password().is_none()
924 && parsed.query().is_none()
925 && parsed.fragment().is_none()
926}
927
928#[cfg(test)]
929mod tests {
930 use super::*;
931 use crate::FilesystemWorkspace;
932 use tempfile::TempDir;
933
934 #[tokio::test]
935 async fn explanation_requests_require_origin_bound_tokens_and_report_unsupported() {
936 let temp = TempDir::new().expect("workspace");
937 let adapter = Arc::new(FilesystemWorkspace::new(temp.path(), [], false).await.expect("adapter"));
938 let origin = "https://example.test";
939 let server = WebmcpServer::new(
940 adapter,
941 WebmcpServerConfig {
942 allowed_origins: vec![origin.to_owned(), "https://other.test".to_owned()],
943 ..Default::default()
944 },
945 )
946 .expect("server");
947 let pairing = server.begin_pairing_for_origin(origin).expect("pairing");
948 let session = server
949 .state
950 .pairing
951 .pair(pairing.code(), origin)
952 .expect("authenticated session");
953 let token = session.token().to_owned();
954 let reference = vtcode_memory::explanation::EvidenceRef {
955 session_id: "session".into(),
956 offset: 0,
957 length: 1,
958 digest: "0".repeat(64),
959 item_id: None,
960 };
961 let requests = [
962 BridgeRequest::ExplanationGet {
963 request_id: "get".into(),
964 token: token.clone(),
965 scope: vtcode_memory::explanation::ExplanationScope::Task,
966 offset: 0,
967 },
968 BridgeRequest::ExplanationEvidence {
969 request_id: "evidence".into(),
970 token: token.clone(),
971 reference: reference.clone(),
972 offset: 0,
973 },
974 BridgeRequest::ExplanationNavigate {
975 request_id: "navigate".into(),
976 token: token.clone(),
977 reference,
978 },
979 ];
980 for request in &requests {
981 let dispatch = || Arc::clone(&server.state.dispatch);
982 assert!(matches!(
983 dispatch_request(dispatch(), origin.into(), "wrong-token".into(), request.clone()).await,
984 Err(WebmcpError::Unauthorized)
985 ));
986 let error = dispatch_request(dispatch(), origin.into(), token.clone(), request.clone())
987 .await
988 .expect_err("headless adapter does not provide explanations");
989 assert!(matches!(error, WebmcpError::Unsupported(_)));
990 assert_eq!(response_for_error("operation", error).error.expect("error payload").code, "unsupported");
991 }
992 assert!(matches!(
993 dispatch_request(
994 Arc::clone(&server.state.dispatch),
995 "https://other.test".into(),
996 token.clone(),
997 requests[0].clone()
998 )
999 .await,
1000 Err(WebmcpError::Unauthorized)
1001 ));
1002 for request in requests {
1003 assert!(matches!(
1004 dispatch_request(Arc::clone(&server.state.dispatch), origin.into(), token.clone(), request).await,
1005 Err(WebmcpError::Unauthorized)
1006 ));
1007 }
1008 let status = server.state.adapter.status().await.expect("status");
1009 assert!(!status.explanations_available);
1010 assert!(!status.turns_available);
1011 assert!(std::fs::read_dir(temp.path()).expect("workspace files").next().is_none());
1012 }
1013
1014 #[tokio::test]
1015 async fn server_requires_explicit_origins_and_remote_flags() {
1016 let temp = TempDir::new().expect("temp dir");
1017 let adapter = Arc::new(FilesystemWorkspace::new(temp.path(), [], false).await.expect("adapter"));
1018 assert!(matches!(
1019 WebmcpServer::new(adapter.clone(), WebmcpServerConfig::default()),
1020 Err(WebmcpError::InvalidRequest(_))
1021 ));
1022 let config = WebmcpServerConfig {
1023 host: "0.0.0.0".to_string(),
1024 allowed_origins: vec!["https://example.test".to_string()],
1025 ..Default::default()
1026 };
1027 assert!(matches!(WebmcpServer::new(adapter, config), Err(WebmcpError::InvalidRequest(_))));
1028
1029 let remote_config = WebmcpServerConfig {
1030 host: "0.0.0.0".to_string(),
1031 allowed_origins: vec!["https://example.test".to_string()],
1032 allow_remote: true,
1033 public_url: Some("wss://bridge.example.test/webmcp".to_string()),
1034 ..Default::default()
1035 };
1036 let temp = TempDir::new().expect("temp dir");
1037 let adapter = Arc::new(FilesystemWorkspace::new(temp.path(), [], false).await.expect("adapter"));
1038 assert!(matches!(WebmcpServer::new(adapter, remote_config), Err(WebmcpError::InvalidRequest(_))));
1039
1040 let temp = TempDir::new().expect("temp dir");
1041 let adapter = Arc::new(FilesystemWorkspace::new(temp.path(), [], false).await.expect("adapter"));
1042 let valid_proxy_config = WebmcpServerConfig {
1043 allowed_origins: vec!["https://example.test".to_string()],
1044 allow_remote: true,
1045 public_url: Some("wss://bridge.example.test/webmcp".to_string()),
1046 ..Default::default()
1047 };
1048 assert!(WebmcpServer::new(adapter.clone(), valid_proxy_config).is_ok());
1049 let invalid_public_url_config = WebmcpServerConfig {
1050 allowed_origins: vec!["https://example.test".to_string()],
1051 public_url: Some("ws://bridge.example.test/webmcp".to_string()),
1052 ..Default::default()
1053 };
1054 assert!(matches!(WebmcpServer::new(adapter, invalid_public_url_config), Err(WebmcpError::InvalidRequest(_))));
1055
1056 let temp = TempDir::new().expect("temp dir");
1057 let adapter = Arc::new(FilesystemWorkspace::new(temp.path(), [], false).await.expect("adapter"));
1058 let invalid_public_url_config = WebmcpServerConfig {
1059 allowed_origins: vec!["https://example.test".to_string()],
1060 allow_remote: true,
1061 public_url: Some("wss://".to_string()),
1062 ..Default::default()
1063 };
1064 assert!(matches!(WebmcpServer::new(adapter, invalid_public_url_config), Err(WebmcpError::InvalidRequest(_))));
1065 }
1066}