Skip to main content

vtcode_config/
webmcp.rs

1use anyhow::{Context, Result, bail};
2use serde::{Deserialize, Serialize};
3use std::net::IpAddr;
4use std::path::PathBuf;
5use vtcode_commons::validation::is_valid_origin;
6
7const MIN_FRAME_BYTES: usize = 1024;
8const DEFAULT_REMOTE_MCP_PROXY_TOKEN_ENV: &str = "VTCODE_WEBMCP_MCP_PROXY_TOKEN";
9const DEFAULT_REMOTE_MCP_MAX_RESULTS: usize = 20;
10const DEFAULT_REMOTE_MCP_MAX_SCAN_FILES: usize = 256;
11const DEFAULT_REMOTE_MCP_MAX_SCAN_BYTES: usize = 16 * 1024 * 1024;
12const DEFAULT_REMOTE_MCP_SESSION_TTL_SECS: u64 = 300;
13
14/// Configuration for the opt-in VT Code WebMCP bridge.
15#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
17pub struct WebmcpConfig {
18    /// Opt-in marker for WebMCP integrations; listener startup still requires an explicit CLI or TUI command.
19    #[serde(default)]
20    pub enabled: bool,
21    /// Literal loopback bind host.
22    #[serde(default = "default_host")]
23    pub host: String,
24    /// Bind port. Zero asks the OS for an available port.
25    #[serde(default)]
26    pub port: u16,
27    /// Exact browser origins allowed to pair.
28    #[serde(default)]
29    pub allowed_origins: Vec<String>,
30    /// Explicit roots available to headless mode; the current bridge serves one root per process.
31    #[serde(default)]
32    pub allowed_roots: Vec<PathBuf>,
33    /// One-time pairing lifetime and authenticated-session inactivity lease.
34    #[serde(default = "default_pairing_ttl_secs")]
35    pub pairing_ttl_secs: u64,
36    /// Maximum JSON WebSocket frame size.
37    #[serde(default = "default_max_frame_bytes")]
38    pub max_frame_bytes: usize,
39    /// Maximum concurrent bridge operations.
40    #[serde(default = "default_max_in_flight_requests")]
41    pub max_in_flight_requests: usize,
42    /// Explicit opt-in OpenAI-compatible remote MCP transport.
43    #[serde(default)]
44    pub remote_mcp: RemoteMcpConfig,
45}
46
47/// Configuration for the read-only OpenAI-compatible MCP transport.
48#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
50#[serde(default)]
51pub struct RemoteMcpConfig {
52    /// Enable the remote MCP endpoints in `webmcp serve`.
53    pub enabled: bool,
54    /// Canonical externally reachable HTTPS URL, normally ending in `/sse/`.
55    pub public_url: Option<String>,
56    /// External OAuth authorization server URL used by the proxy/identity provider.
57    #[serde(alias = "authorization_server_url")]
58    pub authorization_server: Option<String>,
59    /// Environment variable containing the bearer token injected by the proxy.
60    pub proxy_token_env: String,
61    /// Optional HTTPS/HTTP prefix used to build citation URLs for file IDs.
62    pub citation_url_prefix: Option<String>,
63    /// Separate allowlist for supplied MCP `Origin` headers. Missing Origin is accepted.
64    pub allowed_origins: Vec<String>,
65    /// Maximum results returned by `search`.
66    pub max_results: usize,
67    /// Maximum visible files inspected by `search`.
68    pub max_scan_files: usize,
69    /// Maximum UTF-8 content bytes inspected by `search`.
70    pub max_scan_bytes: usize,
71    /// In-memory legacy SSE session inactivity lifetime.
72    pub session_ttl_secs: u64,
73}
74
75impl Default for RemoteMcpConfig {
76    fn default() -> Self {
77        Self {
78            enabled: false,
79            public_url: None,
80            authorization_server: None,
81            proxy_token_env: default_remote_mcp_proxy_token_env(),
82            citation_url_prefix: None,
83            allowed_origins: Vec::new(),
84            max_results: default_remote_mcp_max_results(),
85            max_scan_files: default_remote_mcp_max_scan_files(),
86            max_scan_bytes: default_remote_mcp_max_scan_bytes(),
87            session_ttl_secs: default_remote_mcp_session_ttl_secs(),
88        }
89    }
90}
91
92impl RemoteMcpConfig {
93    /// Validate remote MCP URLs, limits, and the proxy-token environment name.
94    pub fn validate(&self) -> Result<()> {
95        if self.proxy_token_env.trim().is_empty() || !is_valid_env_name(&self.proxy_token_env) {
96            bail!("webmcp.remote_mcp.proxy_token_env must be a valid environment variable name");
97        }
98        if let Some(public_url) = self.public_url.as_deref()
99            && !is_valid_https_url(public_url)
100        {
101            bail!("webmcp.remote_mcp.public_url must be an absolute HTTPS URL without credentials, query, or fragment");
102        }
103        if let Some(authorization_server) = self.authorization_server.as_deref()
104            && !is_valid_https_url(authorization_server)
105        {
106            bail!(
107                "webmcp.remote_mcp.authorization_server must be an absolute HTTPS URL without credentials, query, or fragment"
108            );
109        }
110        if self.enabled {
111            if self.public_url.is_none() {
112                bail!("webmcp.remote_mcp.public_url is required when remote MCP is enabled");
113            }
114            if self.authorization_server.is_none() {
115                bail!("webmcp.remote_mcp.authorization_server is required when remote MCP is enabled");
116            }
117        }
118        if let Some(citation_url_prefix) = self.citation_url_prefix.as_deref()
119            && !is_valid_citation_url_prefix(citation_url_prefix)
120        {
121            bail!(
122                "webmcp.remote_mcp.citation_url_prefix must be an absolute HTTP(S) URL without credentials, query, or fragment"
123            );
124        }
125        if self.allowed_origins.iter().any(|origin| !is_valid_origin(origin)) {
126            bail!("webmcp.remote_mcp.allowed_origins must contain explicit origins such as https://client.example");
127        }
128        if self.max_results == 0 || self.max_results > 100 {
129            bail!("webmcp.remote_mcp.max_results must be between 1 and 100");
130        }
131        if self.max_scan_files == 0 || self.max_scan_files > 4096 {
132            bail!("webmcp.remote_mcp.max_scan_files must be between 1 and 4096");
133        }
134        if self.max_scan_bytes == 0 || self.max_scan_bytes > 64 * 1024 * 1024 {
135            bail!("webmcp.remote_mcp.max_scan_bytes must be between 1 and 67108864");
136        }
137        if self.session_ttl_secs == 0 || self.session_ttl_secs > 3600 {
138            bail!("webmcp.remote_mcp.session_ttl_secs must be between 1 and 3600");
139        }
140        Ok(())
141    }
142}
143
144impl Default for WebmcpConfig {
145    fn default() -> Self {
146        Self {
147            enabled: false,
148            host: default_host(),
149            port: 0,
150            allowed_origins: Vec::new(),
151            allowed_roots: Vec::new(),
152            pairing_ttl_secs: default_pairing_ttl_secs(),
153            max_frame_bytes: default_max_frame_bytes(),
154            max_in_flight_requests: default_max_in_flight_requests(),
155            remote_mcp: RemoteMcpConfig::default(),
156        }
157    }
158}
159
160impl WebmcpConfig {
161    /// Validate limits and origin syntax without touching the filesystem.
162    pub fn validate(&self) -> Result<()> {
163        if self.host.trim().is_empty() {
164            bail!("webmcp.host must not be empty");
165        }
166        let address = self
167            .host
168            .parse::<IpAddr>()
169            .context("webmcp.host must be a literal IP address")?;
170        if !address.is_loopback() {
171            bail!("webmcp.host must be a loopback address; use a TLS-terminating reverse proxy for remote access");
172        }
173        if self.pairing_ttl_secs == 0 || self.pairing_ttl_secs > 3600 {
174            bail!("webmcp.pairing_ttl_secs must be between 1 and 3600");
175        }
176        if self.max_frame_bytes < MIN_FRAME_BYTES || self.max_frame_bytes > 16 * 1024 * 1024 {
177            bail!("webmcp.max_frame_bytes must be between {MIN_FRAME_BYTES} and 16777216");
178        }
179        if self.max_in_flight_requests == 0 || self.max_in_flight_requests > 64 {
180            bail!("webmcp.max_in_flight_requests must be between 1 and 64");
181        }
182        if self.allowed_origins.iter().any(|origin| !is_valid_origin(origin)) {
183            bail!("webmcp.allowed_origins must contain explicit origins such as https://example.test");
184        }
185        if self.allowed_roots.iter().any(|root| root.as_os_str().is_empty()) {
186            bail!("webmcp.allowed_roots must not contain empty paths");
187        }
188        self.remote_mcp.validate().context("invalid webmcp.remote_mcp configuration")?;
189        Ok(())
190    }
191}
192
193fn default_host() -> String {
194    "127.0.0.1".to_string()
195}
196
197fn is_valid_https_url(url: &str) -> bool {
198    let Ok(parsed) = url::Url::parse(url) else {
199        return false;
200    };
201    url == url.trim()
202        && !url.chars().any(char::is_whitespace)
203        && parsed.scheme() == "https"
204        && parsed.host_str().is_some_and(|host| !host.is_empty())
205        && parsed.username().is_empty()
206        && parsed.password().is_none()
207        && parsed.query().is_none()
208        && parsed.fragment().is_none()
209}
210
211fn is_valid_citation_url_prefix(url: &str) -> bool {
212    let Ok(parsed) = url::Url::parse(url) else {
213        return false;
214    };
215    url == url.trim()
216        && !url.chars().any(char::is_whitespace)
217        && matches!(parsed.scheme(), "http" | "https")
218        && parsed.host_str().is_some_and(|host| !host.is_empty())
219        && parsed.username().is_empty()
220        && parsed.password().is_none()
221        && parsed.query().is_none()
222        && parsed.fragment().is_none()
223}
224
225fn is_valid_env_name(name: &str) -> bool {
226    let mut characters = name.chars();
227    let Some(first) = characters.next() else {
228        return false;
229    };
230    (first == '_' || first.is_ascii_alphabetic())
231        && characters.all(|character| character == '_' || character.is_ascii_alphanumeric())
232}
233
234const fn default_pairing_ttl_secs() -> u64 {
235    300
236}
237
238const fn default_max_frame_bytes() -> usize {
239    1_048_576
240}
241
242const fn default_max_in_flight_requests() -> usize {
243    8
244}
245
246fn default_remote_mcp_proxy_token_env() -> String {
247    DEFAULT_REMOTE_MCP_PROXY_TOKEN_ENV.to_string()
248}
249
250const fn default_remote_mcp_max_results() -> usize {
251    DEFAULT_REMOTE_MCP_MAX_RESULTS
252}
253
254const fn default_remote_mcp_max_scan_files() -> usize {
255    DEFAULT_REMOTE_MCP_MAX_SCAN_FILES
256}
257
258const fn default_remote_mcp_max_scan_bytes() -> usize {
259    DEFAULT_REMOTE_MCP_MAX_SCAN_BYTES
260}
261
262const fn default_remote_mcp_session_ttl_secs() -> u64 {
263    DEFAULT_REMOTE_MCP_SESSION_TTL_SECS
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn defaults_keep_webmcp_disabled_and_loopback_only() {
272        let config = WebmcpConfig::default();
273        assert!(!config.enabled);
274        assert_eq!(config.host, "127.0.0.1");
275        assert_eq!(config.port, 0);
276        assert!(config.allowed_origins.is_empty());
277        assert!(!config.remote_mcp.enabled);
278        assert_eq!(config.remote_mcp.max_results, 20);
279        assert_eq!(config.remote_mcp.max_scan_files, 256);
280        assert_eq!(config.remote_mcp.max_scan_bytes, 16 * 1024 * 1024);
281        config.validate().expect("defaults should validate");
282    }
283
284    #[test]
285    fn invalid_limits_and_origins_are_rejected() {
286        let config = WebmcpConfig { max_in_flight_requests: 0, ..Default::default() };
287        assert!(config.validate().is_err());
288        let config = WebmcpConfig {
289            allowed_origins: vec!["*".to_string()],
290            ..Default::default()
291        };
292        assert!(config.validate().is_err());
293        for origin in [
294            "https://*",
295            "ftp://example.test",
296            "https://example.test/path",
297            "https://user@example.test",
298        ] {
299            let config = WebmcpConfig {
300                allowed_origins: vec![origin.to_string()],
301                ..Default::default()
302            };
303            assert!(config.validate().is_err(), "accepted {origin}");
304        }
305        let config = WebmcpConfig { host: "0.0.0.0".to_string(), ..Default::default() };
306        assert!(config.validate().is_err());
307    }
308
309    #[test]
310    fn remote_mcp_requires_https_metadata_and_valid_bounds() {
311        let config = WebmcpConfig {
312            remote_mcp: RemoteMcpConfig {
313                enabled: true,
314                public_url: Some("http://mcp.example.test/sse/".to_string()),
315                authorization_server: Some("https://auth.example.test".to_string()),
316                ..Default::default()
317            },
318            ..Default::default()
319        };
320        assert!(config.validate().is_err());
321
322        let config = WebmcpConfig {
323            remote_mcp: RemoteMcpConfig {
324                enabled: true,
325                public_url: Some("https://mcp.example.test/sse/".to_string()),
326                authorization_server: Some("https://auth.example.test".to_string()),
327                allowed_origins: vec!["https://client.example.test".to_string()],
328                ..Default::default()
329            },
330            ..Default::default()
331        };
332        config.validate().expect("valid remote MCP config");
333
334        let config = WebmcpConfig {
335            remote_mcp: RemoteMcpConfig { max_scan_bytes: 0, ..Default::default() },
336            ..Default::default()
337        };
338        assert!(config.validate().is_err());
339    }
340}