agent: Rework context server settings (#32793)

This changes the way context servers are organised. We now store a
`source` which indicates if the MCP server is configured manually or
managed by an extension.

Release Notes:

- N/A

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
Bennet Bo Fenner 2025-06-16 17:31:31 +02:00 committed by GitHub
parent c35f22dde0
commit d7db4d4e0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 639 additions and 197 deletions

View file

@ -586,7 +586,7 @@ impl AgentConfiguration {
if let Some(server) = if let Some(server) =
this.get_server(&context_server_id) this.get_server(&context_server_id)
{ {
this.start_server(server, cx).log_err(); this.start_server(server, cx);
} }
}) })
} }

View file

@ -1,7 +1,6 @@
use context_server::ContextServerCommand; use context_server::ContextServerCommand;
use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, prelude::*}; use gpui::{DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, WeakEntity, prelude::*};
use project::project_settings::{ContextServerConfiguration, ProjectSettings}; use project::project_settings::{ContextServerSettings, ProjectSettings};
use serde_json::json;
use settings::update_settings_file; use settings::update_settings_file;
use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*}; use ui::{KeyBinding, Modal, ModalFooter, ModalHeader, Section, Tooltip, prelude::*};
use ui_input::SingleLineInput; use ui_input::SingleLineInput;
@ -81,13 +80,12 @@ impl AddContextServerModal {
update_settings_file::<ProjectSettings>(fs.clone(), cx, |settings, _| { update_settings_file::<ProjectSettings>(fs.clone(), cx, |settings, _| {
settings.context_servers.insert( settings.context_servers.insert(
name.into(), name.into(),
ContextServerConfiguration { ContextServerSettings::Custom {
command: Some(ContextServerCommand { command: ContextServerCommand {
path, path,
args, args,
env: None, env: None,
}), },
settings: Some(json!({})),
}, },
); );
}); });

View file

@ -15,7 +15,7 @@ use markdown::{Markdown, MarkdownElement, MarkdownStyle};
use notifications::status_toast::{StatusToast, ToastIcon}; use notifications::status_toast::{StatusToast, ToastIcon};
use project::{ use project::{
context_server_store::{ContextServerStatus, ContextServerStore}, context_server_store::{ContextServerStatus, ContextServerStore},
project_settings::{ContextServerConfiguration, ProjectSettings}, project_settings::{ContextServerSettings, ProjectSettings},
}; };
use settings::{Settings as _, update_settings_file}; use settings::{Settings as _, update_settings_file};
use theme::ThemeSettings; use theme::ThemeSettings;
@ -175,8 +175,9 @@ impl ConfigureContextServerModal {
let settings_changed = ProjectSettings::get_global(cx) let settings_changed = ProjectSettings::get_global(cx)
.context_servers .context_servers
.get(&id.0) .get(&id.0)
.map_or(true, |config| { .map_or(true, |settings| match settings {
config.settings.as_ref() != Some(&settings_value) ContextServerSettings::Custom { .. } => false,
ContextServerSettings::Extension { settings } => settings != &settings_value,
}); });
let is_running = self.context_server_store.read(cx).status_for_server(&id) let is_running = self.context_server_store.read(cx).status_for_server(&id)
@ -221,17 +222,12 @@ impl ConfigureContextServerModal {
update_settings_file::<ProjectSettings>(workspace.read(cx).app_state().fs.clone(), cx, { update_settings_file::<ProjectSettings>(workspace.read(cx).app_state().fs.clone(), cx, {
let id = id.clone(); let id = id.clone();
|settings, _| { |settings, _| {
if let Some(server_config) = settings.context_servers.get_mut(&id.0) { settings.context_servers.insert(
server_config.settings = Some(settings_value); id.0,
} else { ContextServerSettings::Extension {
settings.context_servers.insert( settings: settings_value,
id.0, },
ContextServerConfiguration { );
settings: Some(settings_value),
..Default::default()
},
);
}
} }
}); });
} }

View file

@ -938,24 +938,33 @@ impl ExtensionImports for WasmState {
})?) })?)
} }
"context_servers" => { "context_servers" => {
let configuration = key let settings = key
.and_then(|key| { .and_then(|key| {
ProjectSettings::get(location, cx) ProjectSettings::get(location, cx)
.context_servers .context_servers
.get(key.as_str()) .get(key.as_str())
}) })
.cloned() .cloned()
.unwrap_or_default(); .context("Failed to get context server configuration")?;
Ok(serde_json::to_string(&settings::ContextServerSettings {
command: configuration.command.map(|command| { match settings {
settings::CommandSettings { project::project_settings::ContextServerSettings::Custom {
command,
} => Ok(serde_json::to_string(&settings::ContextServerSettings {
command: Some(settings::CommandSettings {
path: Some(command.path), path: Some(command.path),
arguments: Some(command.args), arguments: Some(command.args),
env: command.env.map(|env| env.into_iter().collect()), env: command.env.map(|env| env.into_iter().collect()),
} }),
}), settings: None,
settings: configuration.settings, })?),
})?) project::project_settings::ContextServerSettings::Extension {
settings,
} => Ok(serde_json::to_string(&settings::ContextServerSettings {
command: None,
settings: Some(settings),
})?),
}
} }
_ => { _ => {
bail!("Unknown settings category: {}", category); bail!("Unknown settings category: {}", category);

View file

@ -75,3 +75,9 @@ pub(crate) mod m_2025_05_29 {
pub(crate) use settings::SETTINGS_PATTERNS; pub(crate) use settings::SETTINGS_PATTERNS;
} }
pub(crate) mod m_2025_06_16 {
mod settings;
pub(crate) use settings::SETTINGS_PATTERNS;
}

View file

@ -0,0 +1,152 @@
use std::ops::Range;
use tree_sitter::{Query, QueryMatch};
use crate::MigrationPatterns;
pub const SETTINGS_PATTERNS: MigrationPatterns = &[
(
SETTINGS_CUSTOM_CONTEXT_SERVER_PATTERN,
migrate_custom_context_server_settings,
),
(
SETTINGS_EXTENSION_CONTEXT_SERVER_PATTERN,
migrate_extension_context_server_settings,
),
(
SETTINGS_EMPTY_CONTEXT_SERVER_PATTERN,
migrate_empty_context_server_settings,
),
];
const SETTINGS_CUSTOM_CONTEXT_SERVER_PATTERN: &str = r#"(document
(object
(pair
key: (string (string_content) @context-servers)
value: (object
(pair
key: (string)
value: (object
(pair
key: (string (string_content) @previous-key)
value: (object)
)*
(pair
key: (string (string_content) @key)
value: (object)
)
(pair
key: (string (string_content) @next-key)
value: (object)
)*
) @server-settings
)
)
)
)
(#eq? @context-servers "context_servers")
(#eq? @key "command")
(#not-eq? @previous-key "source")
(#not-eq? @next-key "source")
)"#;
fn migrate_custom_context_server_settings(
_contents: &str,
mat: &QueryMatch,
query: &Query,
) -> Option<(Range<usize>, String)> {
let server_settings_index = query.capture_index_for_name("server-settings")?;
let server_settings = mat.nodes_for_capture_index(server_settings_index).next()?;
// Move forward 1 to get inside the object
let start = server_settings.start_byte() + 1;
Some((
start..start,
r#"
"source": "custom","#
.to_string(),
))
}
const SETTINGS_EXTENSION_CONTEXT_SERVER_PATTERN: &str = r#"(document
(object
(pair
key: (string (string_content) @context-servers)
value: (object
(pair
key: (string)
value: (object
(pair
key: (string (string_content) @previous-key)
value: (object)
)*
(pair
key: (string (string_content) @key)
value: (object)
)
(pair
key: (string (string_content) @next-key)
value: (object)
)*
) @server-settings
)
)
)
)
(#eq? @context-servers "context_servers")
(#eq? @key "settings")
(#not-match? @previous-key "^command|source$")
(#not-match? @next-key "^command|source$")
)"#;
fn migrate_extension_context_server_settings(
_contents: &str,
mat: &QueryMatch,
query: &Query,
) -> Option<(Range<usize>, String)> {
let server_settings_index = query.capture_index_for_name("server-settings")?;
let server_settings = mat.nodes_for_capture_index(server_settings_index).next()?;
// Move forward 1 to get inside the object
let start = server_settings.start_byte() + 1;
Some((
start..start,
r#"
"source": "extension","#
.to_string(),
))
}
const SETTINGS_EMPTY_CONTEXT_SERVER_PATTERN: &str = r#"(document
(object
(pair
key: (string (string_content) @context-servers)
value: (object
(pair
key: (string)
value: (object) @server-settings
)
)
)
)
(#eq? @context-servers "context_servers")
(#eq? @server-settings "{}")
)"#;
fn migrate_empty_context_server_settings(
_contents: &str,
mat: &QueryMatch,
query: &Query,
) -> Option<(Range<usize>, String)> {
let server_settings_index = query.capture_index_for_name("server-settings")?;
let server_settings = mat.nodes_for_capture_index(server_settings_index).next()?;
Some((
server_settings.byte_range(),
r#"{
"source": "extension",
"settings": {}
}"#
.to_string(),
))
}

View file

@ -148,6 +148,10 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
migrations::m_2025_05_29::SETTINGS_PATTERNS, migrations::m_2025_05_29::SETTINGS_PATTERNS,
&SETTINGS_QUERY_2025_05_29, &SETTINGS_QUERY_2025_05_29,
), ),
(
migrations::m_2025_06_16::SETTINGS_PATTERNS,
&SETTINGS_QUERY_2025_06_16,
),
]; ];
run_migrations(text, migrations) run_migrations(text, migrations)
} }
@ -246,6 +250,10 @@ define_query!(
SETTINGS_QUERY_2025_05_29, SETTINGS_QUERY_2025_05_29,
migrations::m_2025_05_29::SETTINGS_PATTERNS migrations::m_2025_05_29::SETTINGS_PATTERNS
); );
define_query!(
SETTINGS_QUERY_2025_06_16,
migrations::m_2025_06_16::SETTINGS_PATTERNS
);
// custom query // custom query
static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| { static EDIT_PREDICTION_SETTINGS_MIGRATION_QUERY: LazyLock<Query> = LazyLock::new(|| {
@ -854,4 +862,152 @@ mod tests {
), ),
); );
} }
#[test]
fn test_mcp_settings_migration() {
assert_migrate_settings(
r#"{
"context_servers": {
"empty_server": {},
"extension_server": {
"settings": {
"foo": "bar"
}
},
"custom_server": {
"command": {
"path": "foo",
"args": ["bar"],
"env": {
"FOO": "BAR"
}
}
},
"invalid_server": {
"command": {
"path": "foo",
"args": ["bar"],
"env": {
"FOO": "BAR"
}
},
"settings": {
"foo": "bar"
}
},
"empty_server2": {},
"extension_server2": {
"foo": "bar",
"settings": {
"foo": "bar"
},
"bar": "foo"
},
"custom_server2": {
"foo": "bar",
"command": {
"path": "foo",
"args": ["bar"],
"env": {
"FOO": "BAR"
}
},
"bar": "foo"
},
"invalid_server2": {
"foo": "bar",
"command": {
"path": "foo",
"args": ["bar"],
"env": {
"FOO": "BAR"
}
},
"bar": "foo",
"settings": {
"foo": "bar"
}
}
}
}"#,
Some(
r#"{
"context_servers": {
"empty_server": {
"source": "extension",
"settings": {}
},
"extension_server": {
"source": "extension",
"settings": {
"foo": "bar"
}
},
"custom_server": {
"source": "custom",
"command": {
"path": "foo",
"args": ["bar"],
"env": {
"FOO": "BAR"
}
}
},
"invalid_server": {
"source": "custom",
"command": {
"path": "foo",
"args": ["bar"],
"env": {
"FOO": "BAR"
}
},
"settings": {
"foo": "bar"
}
},
"empty_server2": {
"source": "extension",
"settings": {}
},
"extension_server2": {
"source": "extension",
"foo": "bar",
"settings": {
"foo": "bar"
},
"bar": "foo"
},
"custom_server2": {
"source": "custom",
"foo": "bar",
"command": {
"path": "foo",
"args": ["bar"],
"env": {
"FOO": "BAR"
}
},
"bar": "foo"
},
"invalid_server2": {
"source": "custom",
"foo": "bar",
"command": {
"path": "foo",
"args": ["bar"],
"env": {
"FOO": "BAR"
}
},
"bar": "foo",
"settings": {
"foo": "bar"
}
}
}
}"#,
),
);
}
} }

View file

@ -5,14 +5,15 @@ use std::{path::Path, sync::Arc};
use anyhow::{Context as _, Result}; use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet}; use collections::{HashMap, HashSet};
use context_server::{ContextServer, ContextServerId}; use context_server::{ContextServer, ContextServerCommand, ContextServerId};
use futures::{FutureExt as _, future::join_all};
use gpui::{App, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, actions}; use gpui::{App, AsyncApp, Context, Entity, EventEmitter, Subscription, Task, WeakEntity, actions};
use registry::ContextServerDescriptorRegistry; use registry::ContextServerDescriptorRegistry;
use settings::{Settings as _, SettingsStore}; use settings::{Settings as _, SettingsStore};
use util::ResultExt as _; use util::ResultExt as _;
use crate::{ use crate::{
project_settings::{ContextServerConfiguration, ProjectSettings}, project_settings::{ContextServerSettings, ProjectSettings},
worktree_store::WorktreeStore, worktree_store::WorktreeStore,
}; };
@ -81,6 +82,50 @@ impl ContextServerState {
} }
} }
#[derive(PartialEq, Eq)]
pub enum ContextServerConfiguration {
Custom {
command: ContextServerCommand,
},
Extension {
command: ContextServerCommand,
settings: serde_json::Value,
},
}
impl ContextServerConfiguration {
pub fn command(&self) -> &ContextServerCommand {
match self {
ContextServerConfiguration::Custom { command } => command,
ContextServerConfiguration::Extension { command, .. } => command,
}
}
pub async fn from_settings(
settings: ContextServerSettings,
id: ContextServerId,
registry: Entity<ContextServerDescriptorRegistry>,
worktree_store: Entity<WorktreeStore>,
cx: &AsyncApp,
) -> Option<Self> {
match settings {
ContextServerSettings::Custom { command } => {
Some(ContextServerConfiguration::Custom { command })
}
ContextServerSettings::Extension { settings } => {
let descriptor = cx
.update(|cx| registry.read(cx).context_server_descriptor(&id.0))
.ok()
.flatten()?;
let command = descriptor.command(worktree_store, cx).await.log_err()?;
Some(ContextServerConfiguration::Extension { command, settings })
}
}
}
}
pub type ContextServerFactory = pub type ContextServerFactory =
Box<dyn Fn(ContextServerId, Arc<ContextServerConfiguration>) -> Arc<ContextServer>>; Box<dyn Fn(ContextServerId, Arc<ContextServerConfiguration>) -> Arc<ContextServer>>;
@ -207,29 +252,37 @@ impl ContextServerStore {
.collect() .collect()
} }
pub fn start_server( pub fn start_server(&mut self, server: Arc<ContextServer>, cx: &mut Context<Self>) {
&mut self, cx.spawn(async move |this, cx| {
server: Arc<ContextServer>, let this = this.upgrade().context("Context server store dropped")?;
cx: &mut Context<Self>, let settings = this
) -> Result<()> { .update(cx, |this, cx| {
let location = self this.context_server_settings(cx)
.worktree_store .get(&server.id().0)
.read(cx) .cloned()
.visible_worktrees(cx) })
.next() .ok()
.map(|worktree| settings::SettingsLocation { .flatten()
worktree_id: worktree.read(cx).id(), .context("Failed to get context server settings")?;
path: Path::new(""),
});
let settings = ProjectSettings::get(location, cx);
let configuration = settings
.context_servers
.get(&server.id().0)
.context("Failed to load context server configuration from settings")?
.clone();
self.run_server(server, Arc::new(configuration), cx); let (registry, worktree_store) = this.update(cx, |this, _| {
Ok(()) (this.registry.clone(), this.worktree_store.clone())
})?;
let configuration = ContextServerConfiguration::from_settings(
settings,
server.id(),
registry,
worktree_store,
cx,
)
.await
.context("Failed to create context server configuration")?;
this.update(cx, |this, cx| {
this.run_server(server, Arc::new(configuration), cx)
})
})
.detach_and_log_err(cx);
} }
pub fn stop_server(&mut self, id: &ContextServerId, cx: &mut Context<Self>) -> Result<()> { pub fn stop_server(&mut self, id: &ContextServerId, cx: &mut Context<Self>) -> Result<()> {
@ -349,11 +402,6 @@ impl ContextServerStore {
Ok(()) Ok(())
} }
fn is_configuration_valid(&self, configuration: &ContextServerConfiguration) -> bool {
// Command must be some when we are running in stdio mode.
self.context_server_factory.as_ref().is_some() || configuration.command.is_some()
}
fn create_context_server( fn create_context_server(
&self, &self,
id: ContextServerId, id: ContextServerId,
@ -362,14 +410,29 @@ impl ContextServerStore {
if let Some(factory) = self.context_server_factory.as_ref() { if let Some(factory) = self.context_server_factory.as_ref() {
Ok(factory(id, configuration)) Ok(factory(id, configuration))
} else { } else {
let command = configuration Ok(Arc::new(ContextServer::stdio(
.command id,
.clone() configuration.command().clone(),
.context("Missing command to run context server")?; )))
Ok(Arc::new(ContextServer::stdio(id, command)))
} }
} }
fn context_server_settings<'a>(
&'a self,
cx: &'a App,
) -> &'a HashMap<Arc<str>, ContextServerSettings> {
let location = self
.worktree_store
.read(cx)
.visible_worktrees(cx)
.next()
.map(|worktree| settings::SettingsLocation {
worktree_id: worktree.read(cx).id(),
path: Path::new(""),
});
&ProjectSettings::get(location, cx).context_servers
}
fn update_server_state( fn update_server_state(
&mut self, &mut self,
id: ContextServerId, id: ContextServerId,
@ -407,43 +470,39 @@ impl ContextServerStore {
} }
async fn maintain_servers(this: WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> { async fn maintain_servers(this: WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
let mut desired_servers = HashMap::default(); let (mut configured_servers, registry, worktree_store) = this.update(cx, |this, cx| {
(
let (registry, worktree_store) = this.update(cx, |this, cx| { this.context_server_settings(cx).clone(),
let location = this this.registry.clone(),
.worktree_store this.worktree_store.clone(),
.read(cx) )
.visible_worktrees(cx)
.next()
.map(|worktree| settings::SettingsLocation {
worktree_id: worktree.read(cx).id(),
path: Path::new(""),
});
let settings = ProjectSettings::get(location, cx);
desired_servers = settings.context_servers.clone();
(this.registry.clone(), this.worktree_store.clone())
})?; })?;
for (id, descriptor) in for (id, _) in
registry.read_with(cx, |registry, _| registry.context_server_descriptors())? registry.read_with(cx, |registry, _| registry.context_server_descriptors())?
{ {
let config = desired_servers.entry(id.clone()).or_default(); configured_servers
if config.command.is_none() { .entry(id)
if let Some(extension_command) = descriptor .or_insert(ContextServerSettings::Extension {
.command(worktree_store.clone(), &cx) settings: serde_json::json!({}),
.await });
.log_err()
{
config.command = Some(extension_command);
}
}
} }
this.update(cx, |this, _| { let configured_servers = join_all(configured_servers.into_iter().map(|(id, settings)| {
// Filter out configurations without commands, the user uninstalled an extension. let id = ContextServerId(id);
desired_servers.retain(|_, configuration| this.is_configuration_valid(configuration)); ContextServerConfiguration::from_settings(
})?; settings,
id.clone(),
registry.clone(),
worktree_store.clone(),
cx,
)
.map(|config| (id, config))
}))
.await
.into_iter()
.filter_map(|(id, config)| config.map(|config| (id, config)))
.collect::<HashMap<_, _>>();
let mut servers_to_start = Vec::new(); let mut servers_to_start = Vec::new();
let mut servers_to_remove = HashSet::default(); let mut servers_to_remove = HashSet::default();
@ -452,16 +511,13 @@ impl ContextServerStore {
this.update(cx, |this, _cx| { this.update(cx, |this, _cx| {
for server_id in this.servers.keys() { for server_id in this.servers.keys() {
// All servers that are not in desired_servers should be removed from the store. // All servers that are not in desired_servers should be removed from the store.
// E.g. this can happen if the user removed a server from the configuration, // This can happen if the user removed a server from the context server settings.
// or the user uninstalled an extension. if !configured_servers.contains_key(&server_id) {
if !desired_servers.contains_key(&server_id.0) {
servers_to_remove.insert(server_id.clone()); servers_to_remove.insert(server_id.clone());
} }
} }
for (id, config) in desired_servers { for (id, config) in configured_servers {
let id = ContextServerId(id.clone());
let existing_config = this.servers.get(&id).map(|state| state.configuration()); let existing_config = this.servers.get(&id).map(|state| state.configuration());
if existing_config.as_deref() != Some(&config) { if existing_config.as_deref() != Some(&config) {
let config = Arc::new(config); let config = Arc::new(config);
@ -478,27 +534,28 @@ impl ContextServerStore {
} }
})?; })?;
for id in servers_to_stop { this.update(cx, |this, cx| {
this.update(cx, |this, cx| this.stop_server(&id, cx).ok())?; for id in servers_to_stop {
} this.stop_server(&id, cx)?;
}
for id in servers_to_remove { for id in servers_to_remove {
this.update(cx, |this, cx| this.remove_server(&id, cx).ok())?; this.remove_server(&id, cx)?;
} }
for (server, config) in servers_to_start {
for (server, config) in servers_to_start { this.run_server(server, config, cx);
this.update(cx, |this, cx| this.run_server(server, config, cx)) }
.log_err(); anyhow::Ok(())
} })?
Ok(())
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{FakeFs, Project, project_settings::ProjectSettings}; use crate::{
FakeFs, Project, context_server_store::registry::ContextServerDescriptor,
project_settings::ProjectSettings,
};
use context_server::test::create_fake_transport; use context_server::test::create_fake_transport;
use gpui::{AppContext, TestAppContext, UpdateGlobal as _}; use gpui::{AppContext, TestAppContext, UpdateGlobal as _};
use serde_json::json; use serde_json::json;
@ -514,8 +571,8 @@ mod tests {
cx, cx,
json!({"code.rs": ""}), json!({"code.rs": ""}),
vec![ vec![
(SERVER_1_ID.into(), ContextServerConfiguration::default()), (SERVER_1_ID.into(), dummy_server_settings()),
(SERVER_2_ID.into(), ContextServerConfiguration::default()), (SERVER_2_ID.into(), dummy_server_settings()),
], ],
) )
.await; .await;
@ -537,9 +594,7 @@ mod tests {
Arc::new(create_fake_transport(SERVER_2_ID, cx.executor())), Arc::new(create_fake_transport(SERVER_2_ID, cx.executor())),
)); ));
store store.update(cx, |store, cx| store.start_server(server_1, cx));
.update(cx, |store, cx| store.start_server(server_1, cx))
.unwrap();
cx.run_until_parked(); cx.run_until_parked();
@ -551,9 +606,7 @@ mod tests {
assert_eq!(store.read(cx).status_for_server(&server_2_id), None); assert_eq!(store.read(cx).status_for_server(&server_2_id), None);
}); });
store store.update(cx, |store, cx| store.start_server(server_2.clone(), cx));
.update(cx, |store, cx| store.start_server(server_2.clone(), cx))
.unwrap();
cx.run_until_parked(); cx.run_until_parked();
@ -593,8 +646,8 @@ mod tests {
cx, cx,
json!({"code.rs": ""}), json!({"code.rs": ""}),
vec![ vec![
(SERVER_1_ID.into(), ContextServerConfiguration::default()), (SERVER_1_ID.into(), dummy_server_settings()),
(SERVER_2_ID.into(), ContextServerConfiguration::default()), (SERVER_2_ID.into(), dummy_server_settings()),
], ],
) )
.await; .await;
@ -628,15 +681,11 @@ mod tests {
cx, cx,
); );
store store.update(cx, |store, cx| store.start_server(server_1, cx));
.update(cx, |store, cx| store.start_server(server_1, cx))
.unwrap();
cx.run_until_parked(); cx.run_until_parked();
store store.update(cx, |store, cx| store.start_server(server_2.clone(), cx));
.update(cx, |store, cx| store.start_server(server_2.clone(), cx))
.unwrap();
cx.run_until_parked(); cx.run_until_parked();
@ -652,7 +701,7 @@ mod tests {
let (_fs, project) = setup_context_server_test( let (_fs, project) = setup_context_server_test(
cx, cx,
json!({"code.rs": ""}), json!({"code.rs": ""}),
vec![(SERVER_1_ID.into(), ContextServerConfiguration::default())], vec![(SERVER_1_ID.into(), dummy_server_settings())],
) )
.await; .await;
@ -684,21 +733,11 @@ mod tests {
cx, cx,
); );
store store.update(cx, |store, cx| {
.update(cx, |store, cx| { store.start_server(server_with_same_id_1.clone(), cx)
store.start_server(server_with_same_id_1.clone(), cx) });
}) store.update(cx, |store, cx| {
.unwrap(); store.start_server(server_with_same_id_2.clone(), cx)
store
.update(cx, |store, cx| {
store.start_server(server_with_same_id_2.clone(), cx)
})
.unwrap();
cx.update(|cx| {
assert_eq!(
store.read(cx).status_for_server(&server_id),
Some(ContextServerStatus::Starting)
);
}); });
cx.run_until_parked(); cx.run_until_parked();
@ -719,23 +758,28 @@ mod tests {
let server_1_id = ContextServerId(SERVER_1_ID.into()); let server_1_id = ContextServerId(SERVER_1_ID.into());
let server_2_id = ContextServerId(SERVER_2_ID.into()); let server_2_id = ContextServerId(SERVER_2_ID.into());
let fake_descriptor_1 = Arc::new(FakeContextServerDescriptor::new(SERVER_1_ID));
let (_fs, project) = setup_context_server_test( let (_fs, project) = setup_context_server_test(
cx, cx,
json!({"code.rs": ""}), json!({"code.rs": ""}),
vec![( vec![(
SERVER_1_ID.into(), SERVER_1_ID.into(),
ContextServerConfiguration { ContextServerSettings::Extension {
command: None, settings: json!({
settings: Some(json!({
"somevalue": true "somevalue": true
})), }),
}, },
)], )],
) )
.await; .await;
let executor = cx.executor(); let executor = cx.executor();
let registry = cx.new(|_| ContextServerDescriptorRegistry::new()); let registry = cx.new(|_| {
let mut registry = ContextServerDescriptorRegistry::new();
registry.register_context_server_descriptor(SERVER_1_ID.into(), fake_descriptor_1);
registry
});
let store = cx.new(|cx| { let store = cx.new(|cx| {
ContextServerStore::test_maintain_server_loop( ContextServerStore::test_maintain_server_loop(
Box::new(move |id, _| { Box::new(move |id, _| {
@ -777,11 +821,10 @@ mod tests {
set_context_server_configuration( set_context_server_configuration(
vec![( vec![(
server_1_id.0.clone(), server_1_id.0.clone(),
ContextServerConfiguration { ContextServerSettings::Extension {
command: None, settings: json!({
settings: Some(json!({
"somevalue": false "somevalue": false
})), }),
}, },
)], )],
cx, cx,
@ -796,11 +839,10 @@ mod tests {
set_context_server_configuration( set_context_server_configuration(
vec![( vec![(
server_1_id.0.clone(), server_1_id.0.clone(),
ContextServerConfiguration { ContextServerSettings::Extension {
command: None, settings: json!({
settings: Some(json!({
"somevalue": false "somevalue": false
})), }),
}, },
)], )],
cx, cx,
@ -823,20 +865,58 @@ mod tests {
vec![ vec![
( (
server_1_id.0.clone(), server_1_id.0.clone(),
ContextServerConfiguration { ContextServerSettings::Extension {
command: None, settings: json!({
settings: Some(json!({
"somevalue": false "somevalue": false
})), }),
}, },
), ),
( (
server_2_id.0.clone(), server_2_id.0.clone(),
ContextServerConfiguration { ContextServerSettings::Custom {
command: None, command: ContextServerCommand {
settings: Some(json!({ path: "somebinary".to_string(),
"somevalue": true args: vec!["arg".to_string()],
})), env: None,
},
},
),
],
cx,
);
cx.run_until_parked();
}
// Ensure that mcp-2 is restarted once the args have changed
{
let _server_events = assert_server_events(
&store,
vec![
(server_2_id.clone(), ContextServerStatus::Stopped),
(server_2_id.clone(), ContextServerStatus::Starting),
(server_2_id.clone(), ContextServerStatus::Running),
],
cx,
);
set_context_server_configuration(
vec![
(
server_1_id.0.clone(),
ContextServerSettings::Extension {
settings: json!({
"somevalue": false
}),
},
),
(
server_2_id.0.clone(),
ContextServerSettings::Custom {
command: ContextServerCommand {
path: "somebinary".to_string(),
args: vec!["anotherArg".to_string()],
env: None,
},
}, },
), ),
], ],
@ -856,11 +936,10 @@ mod tests {
set_context_server_configuration( set_context_server_configuration(
vec![( vec![(
server_1_id.0.clone(), server_1_id.0.clone(),
ContextServerConfiguration { ContextServerSettings::Extension {
command: None, settings: json!({
settings: Some(json!({
"somevalue": false "somevalue": false
})), }),
}, },
)], )],
cx, cx,
@ -875,7 +954,7 @@ mod tests {
} }
fn set_context_server_configuration( fn set_context_server_configuration(
context_servers: Vec<(Arc<str>, ContextServerConfiguration)>, context_servers: Vec<(Arc<str>, ContextServerSettings)>,
cx: &mut TestAppContext, cx: &mut TestAppContext,
) { ) {
cx.update(|cx| { cx.update(|cx| {
@ -909,6 +988,16 @@ mod tests {
} }
} }
fn dummy_server_settings() -> ContextServerSettings {
ContextServerSettings::Custom {
command: ContextServerCommand {
path: "somebinary".to_string(),
args: vec!["arg".to_string()],
env: None,
},
}
}
fn assert_server_events( fn assert_server_events(
store: &Entity<ContextServerStore>, store: &Entity<ContextServerStore>,
expected_events: Vec<(ContextServerId, ContextServerStatus)>, expected_events: Vec<(ContextServerId, ContextServerStatus)>,
@ -953,7 +1042,7 @@ mod tests {
async fn setup_context_server_test( async fn setup_context_server_test(
cx: &mut TestAppContext, cx: &mut TestAppContext,
files: serde_json::Value, files: serde_json::Value,
context_server_configurations: Vec<(Arc<str>, ContextServerConfiguration)>, context_server_configurations: Vec<(Arc<str>, ContextServerSettings)>,
) -> (Arc<FakeFs>, Entity<Project>) { ) -> (Arc<FakeFs>, Entity<Project>) {
cx.update(|cx| { cx.update(|cx| {
let settings_store = SettingsStore::test(cx); let settings_store = SettingsStore::test(cx);
@ -972,4 +1061,36 @@ mod tests {
(fs, project) (fs, project)
} }
struct FakeContextServerDescriptor {
path: String,
}
impl FakeContextServerDescriptor {
fn new(path: impl Into<String>) -> Self {
Self { path: path.into() }
}
}
impl ContextServerDescriptor for FakeContextServerDescriptor {
fn command(
&self,
_worktree_store: Entity<WorktreeStore>,
_cx: &AsyncApp,
) -> Task<Result<ContextServerCommand>> {
Task::ready(Ok(ContextServerCommand {
path: self.path.clone(),
args: vec!["arg1".to_string(), "arg2".to_string()],
env: None,
}))
}
fn configuration(
&self,
_worktree_store: Entity<WorktreeStore>,
_cx: &AsyncApp,
) -> Task<Result<Option<::extension::ContextServerConfiguration>>> {
Task::ready(Ok(None))
}
}
} }

View file

@ -55,7 +55,7 @@ pub struct ProjectSettings {
/// Settings for context servers used for AI-related features. /// Settings for context servers used for AI-related features.
#[serde(default)] #[serde(default)]
pub context_servers: HashMap<Arc<str>, ContextServerConfiguration>, pub context_servers: HashMap<Arc<str>, ContextServerSettings>,
/// Configuration for Diagnostics-related features. /// Configuration for Diagnostics-related features.
#[serde(default)] #[serde(default)]
@ -84,17 +84,22 @@ pub struct DapSettings {
pub binary: Option<String>, pub binary: Option<String>,
} }
#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug, Default)] #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)]
pub struct ContextServerConfiguration { #[serde(tag = "source", rename_all = "snake_case")]
/// The command to run this context server. pub enum ContextServerSettings {
/// Custom {
/// This will override the command set by an extension. /// The command to run this context server.
pub command: Option<ContextServerCommand>, ///
/// The settings for this context server. /// This will override the command set by an extension.
/// command: ContextServerCommand,
/// Consult the documentation for the context server to see what settings },
/// are supported. Extension {
pub settings: Option<serde_json::Value>, /// The settings for this context server specified by the extension.
///
/// Consult the documentation for the context server to see what settings
/// are supported.
settings: serde_json::Value,
},
} }
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
@ -472,13 +477,12 @@ impl Settings for ProjectSettings {
.extend(mcp.iter().filter_map(|(k, v)| { .extend(mcp.iter().filter_map(|(k, v)| {
Some(( Some((
k.clone().into(), k.clone().into(),
ContextServerConfiguration { ContextServerSettings::Custom {
command: Some( command: serde_json::from_value::<VsCodeContextServerCommand>(
serde_json::from_value::<VsCodeContextServerCommand>(v.clone()) v.clone(),
.ok()? )
.into(), .ok()?
), .into(),
settings: None,
}, },
)) ))
})); }));

View file

@ -41,12 +41,12 @@ You can connect them by adding their commands directly to your `settings.json`,
{ {
"context_servers": { "context_servers": {
"some-context-server": { "some-context-server": {
"source": "custom",
"command": { "command": {
"path": "some-command", "path": "some-command",
"args": ["arg-1", "arg-2"], "args": ["arg-1", "arg-2"],
"env": {} "env": {}
}, }
"settings": {}
} }
} }
} }