Skip to main content

winx_code_agent/
http_server.rs

1//! Optional remote MCP transport over Streamable HTTP.
2//!
3//! This is what lets a cloud client (e.g. ChatGPT "developer mode" custom
4//! connectors) talk to winx. The default stdio transport is for local clients
5//! (Claude Desktop, Cursor); this one serves the MCP protocol over HTTP at
6//! `/mcp` so it can sit behind an HTTPS tunnel (cloudflared/ngrok).
7//!
8//! # SECURITY
9//! winx exposes arbitrary shell execution and filesystem access. Serving it over
10//! the network is effectively remote code execution on this machine. Therefore:
11//! - a non-empty bearer token is **required**; every request must present it via
12//!   `Authorization: Bearer <token>` or a `?token=<token>` query parameter;
13//! - bind to a loopback address and put an authenticated HTTPS tunnel in front —
14//!   never expose this straight to `0.0.0.0` on an untrusted network;
15//! - turn it off when you're done testing.
16
17// Module docs name products (ChatGPT, OAuth, cloudflared) — prose, not code idents.
18#![allow(clippy::doc_markdown)]
19
20use std::sync::Arc;
21
22use axum::{
23    extract::{Request, State},
24    http::StatusCode,
25    middleware::{self, Next},
26    response::{IntoResponse, Response},
27    Router,
28};
29use rmcp::transport::streamable_http_server::{
30    session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
31};
32
33use crate::server::WinxService;
34
35type BoxError = Box<dyn std::error::Error + Send + Sync>;
36
37/// Start the Streamable HTTP MCP server.
38///
39/// - `bind`: socket address to listen on, e.g. `127.0.0.1:8000`.
40/// - `token`: shared secret required on every request (must be non-empty).
41/// - `extra_hosts`: additional `Host` authorities to accept beyond loopback —
42///   add your tunnel hostname here (e.g. `abc.trycloudflare.com`), otherwise the
43///   built-in DNS-rebinding guard rejects requests coming through the tunnel.
44pub async fn start_http_server(
45    bind: &str,
46    token: String,
47    extra_hosts: Vec<String>,
48) -> Result<(), BoxError> {
49    if token.trim().is_empty() {
50        return Err("refusing to start HTTP transport without a token (RCE exposure)".into());
51    }
52
53    let mut config = StreamableHttpServerConfig::default();
54    config.stateful_mode = true;
55    config.allowed_hosts.extend(extra_hosts);
56
57    // One shared WinxService across every request. Remote clients like ChatGPT
58    // are effectively stateless — they don't reuse the MCP session between tool
59    // calls — so a per-session service would throw away the shell that
60    // `Initialize` created before `BashCommand` runs ("Bash state not
61    // initialized"). Sharing one instance keeps shells alive for the server's
62    // lifetime; isolation between logical sessions is provided per `thread_id`
63    // by the service's internal session registry, so concurrent clients no
64    // longer share a single shell.
65    let shared = WinxService::new();
66    let mcp_service = StreamableHttpService::new(
67        move || Ok(shared.clone()),
68        Arc::new(LocalSessionManager::default()),
69        config,
70    );
71
72    let app = Router::new()
73        .nest_service("/mcp", mcp_service)
74        .layer(middleware::from_fn_with_state(Arc::new(token), require_token));
75
76    let listener = tokio::net::TcpListener::bind(bind).await?;
77    tracing::warn!(
78        "winx remote MCP transport on http://{bind}/mcp — shell/file access is now \
79         network-reachable. Keep it behind an HTTPS tunnel and shut it down when done."
80    );
81    axum::serve(listener, app).await?;
82    Ok(())
83}
84
85/// Reject any request that doesn't carry the shared token.
86async fn require_token(State(token): State<Arc<String>>, request: Request, next: Next) -> Response {
87    if request_has_token(&request, &token) {
88        next.run(request).await
89    } else {
90        (StatusCode::UNAUTHORIZED, "missing or invalid token\n").into_response()
91    }
92}
93
94/// True if the request presents the token via `Authorization: Bearer` or `?token=`.
95fn request_has_token(request: &Request, expected: &str) -> bool {
96    let header_match = request
97        .headers()
98        .get(axum::http::header::AUTHORIZATION)
99        .and_then(|value| value.to_str().ok())
100        .and_then(|value| value.strip_prefix("Bearer "))
101        .is_some_and(|presented| constant_time_eq(presented.trim(), expected));
102
103    let query_match = request.uri().query().is_some_and(|query| {
104        query
105            .split('&')
106            .filter_map(|pair| pair.split_once('='))
107            .any(|(key, value)| key == "token" && constant_time_eq(value, expected))
108    });
109
110    header_match || query_match
111}
112
113/// Length-aware byte comparison that avoids early-exit on the first mismatch.
114fn constant_time_eq(a: &str, b: &str) -> bool {
115    let (a, b) = (a.as_bytes(), b.as_bytes());
116    if a.len() != b.len() {
117        return false;
118    }
119    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
120}
121
122#[cfg(test)]
123mod tests {
124    use super::constant_time_eq;
125
126    #[test]
127    fn token_comparison() {
128        assert!(constant_time_eq("s3cret", "s3cret"));
129        assert!(!constant_time_eq("s3cret", "s3creT"));
130        assert!(!constant_time_eq("s3cret", "s3cret-longer"));
131        assert!(!constant_time_eq("", "x"));
132    }
133}