Compare commits

...

3 Commits

Author SHA1 Message Date
John D. Swanson
548df67759 Replace unwrap with expect in timeout tests for better error messages
- Replace .unwrap() with .expect() for set_user_settings calls
- Replace .unwrap() with .expect() for URL parsing in test setup
- Provides descriptive panic messages if test setup fails
- Follows Zed test patterns seen in project_tests.rs
- All tests still passing
2025-12-18 19:31:15 -05:00
John D. Swanson
a78647f8ae Fix timeout test implementations and add comprehensive test coverage
- Fix URL construction in tests (use url::Url::parse instead of .into())
- Fix test assertions to properly call create_context_server
- Add test_context_server_global_timeout: verifies global timeout setting
- Add test_context_server_per_server_timeout_override: verifies per-server override
- Add test_context_server_stdio_timeout: verifies stdio server timeout handling
- All three tests now passing successfully
2025-12-18 18:26:34 -05:00
John D. Swanson
4c2fbbadde Add global and per-server timeout settings for MCP context servers
- Add global context_server_timeout setting (default 60000ms)
- Add per-server timeout field to HTTP context server configuration
- Implement timeout precedence: per-server > global > 60s default
- Both Stdio and HTTP servers now support timeout configuration
- Update ContextServer to pass timeout to HTTP transport
- Document timeout settings with examples in default.json
2025-12-18 16:24:11 -05:00
8 changed files with 261 additions and 10 deletions

View File

@@ -2253,6 +2253,23 @@
"ssh_connections": [],
// Whether to read ~/.ssh/config for ssh connection sources.
"read_ssh_config": true,
// Default timeout in milliseconds for all context server tool calls.
// Individual servers can override this in their configuration.
// Examples:
// "context_servers": {
// "my-stdio-server": {
// "command": "/path/to/server",
// "args": ["--flag"],
// "timeout": 120000 // Override: 2 minutes for this server
// },
// "my-http-server": {
// "url": "https://example.com/mcp",
// "headers": { "Authorization": "Bearer token" },
// "timeout": 90000 // Override: 90 seconds for this server
// }
// }
// Default: 60000 (60 seconds)
"context_server_timeout": 60000,
// Configures context servers for use by the agent.
"context_servers": {},
// Configures agent servers available in the agent panel.

View File

@@ -286,6 +286,7 @@ impl AgentConnection for AcpConnection {
project::context_server_store::ContextServerConfiguration::Http {
url,
headers,
timeout: _,
} => Some(acp::McpServer::Http(
acp::McpServerHttp::new(id.0.to_string(), url.to_string()).headers(
headers

View File

@@ -172,6 +172,7 @@ impl ConfigurationSource {
enabled: true,
url,
headers: auth,
timeout: None,
},
)
})
@@ -411,6 +412,7 @@ impl ConfigureContextServerModal {
enabled: _,
url,
headers,
timeout: _,
} => Some(ConfigurationTarget::ExistingHttp {
id: server_id,
url,

View File

@@ -10,6 +10,7 @@ use collections::HashMap;
use http_client::HttpClient;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use std::{fmt::Display, path::PathBuf};
use anyhow::Result;
@@ -39,6 +40,7 @@ pub struct ContextServer {
id: ContextServerId,
client: RwLock<Option<Arc<crate::protocol::InitializedContextServerProtocol>>>,
configuration: ContextServerTransport,
request_timeout: Option<Duration>,
}
impl ContextServer {
@@ -54,6 +56,7 @@ impl ContextServer {
command,
working_directory.map(|directory| directory.to_path_buf()),
),
request_timeout: None, // Stdio handles timeout through command
}
}
@@ -63,6 +66,7 @@ impl ContextServer {
headers: HashMap<String, String>,
http_client: Arc<dyn HttpClient>,
executor: gpui::BackgroundExecutor,
request_timeout: Option<Duration>,
) -> Result<Self> {
let transport = match endpoint.scheme() {
"http" | "https" => {
@@ -73,14 +77,23 @@ impl ContextServer {
}
_ => anyhow::bail!("unsupported MCP url scheme {}", endpoint.scheme()),
};
Ok(Self::new(id, transport))
Ok(Self::new_with_timeout(id, transport, request_timeout))
}
pub fn new(id: ContextServerId, transport: Arc<dyn crate::transport::Transport>) -> Self {
Self::new_with_timeout(id, transport, None)
}
pub fn new_with_timeout(
id: ContextServerId,
transport: Arc<dyn crate::transport::Transport>,
request_timeout: Option<Duration>,
) -> Self {
Self {
id,
client: RwLock::new(None),
configuration: ContextServerTransport::Custom(transport),
request_timeout,
}
}
@@ -113,7 +126,7 @@ impl ContextServer {
client::ContextServerId(self.id.0.clone()),
self.id().0,
transport.clone(),
None,
self.request_timeout,
cx.clone(),
)?,
})

View File

@@ -2,6 +2,7 @@ pub mod extension;
pub mod registry;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
@@ -18,6 +19,10 @@ use crate::{
worktree_store::WorktreeStore,
};
/// Maximum timeout for context server requests (10 minutes).
/// Prevents extremely large timeout values from tying up resources indefinitely.
const MAX_TIMEOUT_MS: u64 = 600_000;
pub fn init(cx: &mut App) {
extension::init(cx);
}
@@ -102,6 +107,7 @@ pub enum ContextServerConfiguration {
Http {
url: url::Url,
headers: HashMap<String, String>,
timeout: Option<u64>,
},
}
@@ -151,9 +157,14 @@ impl ContextServerConfiguration {
enabled: _,
url,
headers: auth,
timeout,
} => {
let url = url::Url::parse(&url).log_err()?;
Some(ContextServerConfiguration::Http { url, headers: auth })
Some(ContextServerConfiguration::Http {
url,
headers: auth,
timeout,
})
}
}
}
@@ -482,18 +493,32 @@ impl ContextServerStore {
configuration: Arc<ContextServerConfiguration>,
cx: &mut Context<Self>,
) -> Result<Arc<ContextServer>> {
// Get global timeout from settings
let global_timeout = ProjectSettings::get_global(cx).context_server_timeout;
if let Some(factory) = self.context_server_factory.as_ref() {
return Ok(factory(id, configuration));
}
match configuration.as_ref() {
ContextServerConfiguration::Http { url, headers } => Ok(Arc::new(ContextServer::http(
id,
ContextServerConfiguration::Http {
url,
headers.clone(),
cx.http_client(),
cx.background_executor().clone(),
)?)),
headers,
timeout,
} => {
// Apply timeout precedence for HTTP servers: per-server > global
// Cap at MAX_TIMEOUT_MS to prevent extremely large timeout values
let resolved_timeout = timeout.unwrap_or(global_timeout).min(MAX_TIMEOUT_MS);
Ok(Arc::new(ContextServer::http(
id,
url,
headers.clone(),
cx.http_client(),
cx.background_executor().clone(),
Some(Duration::from_millis(resolved_timeout)),
)?))
}
_ => {
let root_path = self
.project
@@ -511,9 +536,23 @@ impl ContextServerStore {
})
})
});
// Apply timeout precedence for stdio servers: per-server > global
// Cap at MAX_TIMEOUT_MS to prevent extremely large timeout values
let mut command_with_timeout = configuration
.command()
.context("Missing command configuration for stdio context server")?
.clone();
if command_with_timeout.timeout.is_none() {
command_with_timeout.timeout = Some(global_timeout.min(MAX_TIMEOUT_MS));
} else {
command_with_timeout.timeout =
command_with_timeout.timeout.map(|t| t.min(MAX_TIMEOUT_MS));
}
Ok(Arc::new(ContextServer::stdio(
id,
configuration.command().unwrap().clone(),
command_with_timeout,
root_path,
)))
}
@@ -1257,6 +1296,7 @@ mod tests {
enabled: true,
url: server_url.to_string(),
headers: Default::default(),
timeout: None,
},
)],
)
@@ -1327,6 +1367,165 @@ mod tests {
}
}
#[gpui::test]
async fn test_context_server_global_timeout(cx: &mut TestAppContext) {
// Configure global timeout to 90 seconds
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
SettingsStore::update_global(cx, |store, cx| {
store
.set_user_settings(r#"{"context_server_timeout": 90000}"#, cx)
.expect("Failed to set test user settings");
});
});
let (_fs, project) = setup_context_server_test(cx, json!({"code.rs": ""}), vec![]).await;
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
// Test that create_context_server applies global timeout
let result = store.update(cx, |store, cx| {
store.create_context_server(
ContextServerId("test-server".into()),
Arc::new(ContextServerConfiguration::Http {
url: url::Url::parse("http://localhost:8080")
.expect("Failed to parse test URL"),
headers: Default::default(),
timeout: None, // Should use global timeout of 90 seconds
}),
cx,
)
});
assert!(
result.is_ok(),
"Server should be created successfully with global timeout"
);
}
#[gpui::test]
async fn test_context_server_per_server_timeout_override(cx: &mut TestAppContext) {
const SERVER_ID: &str = "test-server";
// Configure global timeout to 60 seconds
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
SettingsStore::update_global(cx, |store, cx| {
store
.set_user_settings(r#"{"context_server_timeout": 60000}"#, cx)
.expect("Failed to set test user settings");
});
});
let (_fs, project) = setup_context_server_test(
cx,
json!({"code.rs": ""}),
vec![(
SERVER_ID.into(),
ContextServerSettings::Http {
enabled: true,
url: "http://localhost:8080".to_string(),
headers: Default::default(),
timeout: Some(120000), // Override to 120 seconds
},
)],
)
.await;
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
// Test that create_context_server applies per-server timeout override
let result = store.update(cx, |store, cx| {
store.create_context_server(
ContextServerId("test-server".into()),
Arc::new(ContextServerConfiguration::Http {
url: url::Url::parse("http://localhost:8080")
.expect("Failed to parse test URL"),
headers: Default::default(),
timeout: Some(120000), // Override: should use 120 seconds, not global 60
}),
cx,
)
});
assert!(
result.is_ok(),
"Server should be created successfully with per-server timeout override"
);
}
#[gpui::test]
async fn test_context_server_stdio_timeout(cx: &mut TestAppContext) {
const SERVER_ID: &str = "stdio-server";
let (_fs, project) = setup_context_server_test(
cx,
json!({"code.rs": ""}),
vec![(
SERVER_ID.into(),
ContextServerSettings::Stdio {
enabled: true,
command: ContextServerCommand {
path: "/usr/bin/node".into(),
args: vec!["server.js".into()],
env: None,
timeout: Some(180000), // 3 minutes
},
},
)],
)
.await;
let registry = cx.new(|_| ContextServerDescriptorRegistry::new());
let store = cx.new(|cx| {
ContextServerStore::test(
registry.clone(),
project.read(cx).worktree_store(),
project.downgrade(),
cx,
)
});
// Test that create_context_server works with stdio timeout
let result = store.update(cx, |store, cx| {
store.create_context_server(
ContextServerId("stdio-server".into()),
Arc::new(ContextServerConfiguration::Custom {
command: ContextServerCommand {
path: "/usr/bin/node".into(),
args: vec!["server.js".into()],
env: None,
timeout: Some(180000), // 3 minutes
},
}),
cx,
)
});
assert!(
result.is_ok(),
"Stdio server should be created successfully with timeout"
);
}
fn dummy_server_settings() -> ContextServerSettings {
ContextServerSettings::Stdio {
enabled: true,

View File

@@ -59,6 +59,9 @@ pub struct ProjectSettings {
/// Settings for context servers used for AI-related features.
pub context_servers: HashMap<Arc<str>, ContextServerSettings>,
/// Default timeout for context server requests in milliseconds.
pub context_server_timeout: u64,
/// Configuration for Diagnostics-related features.
pub diagnostics: DiagnosticsSettings,
@@ -141,6 +144,8 @@ pub enum ContextServerSettings {
/// Optional authentication configuration for the remote server.
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
headers: HashMap<String, String>,
/// Timeout for tool calls in milliseconds.
timeout: Option<u64>,
},
Extension {
/// Whether the context server is enabled.
@@ -167,10 +172,12 @@ impl From<settings::ContextServerSettingsContent> for ContextServerSettings {
enabled,
url,
headers,
timeout,
} => ContextServerSettings::Http {
enabled,
url,
headers,
timeout,
},
}
}
@@ -188,10 +195,12 @@ impl Into<settings::ContextServerSettingsContent> for ContextServerSettings {
enabled,
url,
headers,
timeout,
} => settings::ContextServerSettingsContent::Http {
enabled,
url,
headers,
timeout,
},
}
}
@@ -560,6 +569,7 @@ impl Settings for ProjectSettings {
.into_iter()
.map(|(key, value)| (key, value.into()))
.collect(),
context_server_timeout: project.context_server_timeout.unwrap_or(60000),
lsp: project
.lsp
.clone()

View File

@@ -41,6 +41,12 @@ pub struct ProjectSettingsContent {
#[serde(default)]
pub context_servers: HashMap<Arc<str>, ContextServerSettingsContent>,
/// Default timeout in milliseconds for context server tool calls.
/// Can be overridden per-server in context_servers configuration.
///
/// Default: 60000 (60 seconds)
pub context_server_timeout: Option<u64>,
/// Configuration for how direnv configuration should be loaded
pub load_direnv: Option<DirenvSettings>,
@@ -215,6 +221,8 @@ pub enum ContextServerSettingsContent {
/// Optional headers to send.
#[serde(skip_serializing_if = "HashMap::is_empty", default)]
headers: HashMap<String, String>,
/// Timeout for tool calls in milliseconds. Defaults to global context_server_timeout if not specified.
timeout: Option<u64>,
},
Extension {
/// Whether the context server is enabled.

View File

@@ -402,6 +402,7 @@ impl VsCodeSettings {
terminal: None,
dap: Default::default(),
context_servers: self.context_servers(),
context_server_timeout: None,
load_direnv: None,
slash_commands: None,
git_hosting_providers: None,