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#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
16#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
17pub struct WebmcpConfig {
18 #[serde(default)]
20 pub enabled: bool,
21 #[serde(default = "default_host")]
23 pub host: String,
24 #[serde(default)]
26 pub port: u16,
27 #[serde(default)]
29 pub allowed_origins: Vec<String>,
30 #[serde(default)]
32 pub allowed_roots: Vec<PathBuf>,
33 #[serde(default = "default_pairing_ttl_secs")]
35 pub pairing_ttl_secs: u64,
36 #[serde(default = "default_max_frame_bytes")]
38 pub max_frame_bytes: usize,
39 #[serde(default = "default_max_in_flight_requests")]
41 pub max_in_flight_requests: usize,
42 #[serde(default)]
44 pub remote_mcp: RemoteMcpConfig,
45}
46
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
49#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
50#[serde(default)]
51pub struct RemoteMcpConfig {
52 pub enabled: bool,
54 pub public_url: Option<String>,
56 #[serde(alias = "authorization_server_url")]
58 pub authorization_server: Option<String>,
59 pub proxy_token_env: String,
61 pub citation_url_prefix: Option<String>,
63 pub allowed_origins: Vec<String>,
65 pub max_results: usize,
67 pub max_scan_files: usize,
69 pub max_scan_bytes: usize,
71 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 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 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}