Remove dependency on goose-mcp from goose crate#6637
Conversation
There was a problem hiding this comment.
Pull request overview
This PR successfully removes the circular dependency between the goose and goose-mcp crates by inverting the dependency relationship. The goose crate now defines the BuiltinDef and SpawnServerFn types, while goose-mcp depends on goose and re-exports these types.
Changes:
- Introduced
builtin_extension.rsin thegoosecrate to define extension types - Updated
AgentManager,AgentConfig,Agent, andExtensionManagerto accept builtin extensions as constructor parameters - Modified all call sites to pass builtin extensions (either
HashMap::new()for tests orgoose_mcp::BUILTIN_EXTENSIONS.clone()for production code)
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
crates/goose/src/builtin_extension.rs |
New module defining BuiltinDef struct and SpawnServerFn type for extension definitions |
crates/goose/src/lib.rs |
Exports the new builtin_extension module |
crates/goose/src/agents/agent.rs |
Updated AgentConfig and Agent to accept and store builtin extensions |
crates/goose/src/agents/extension_manager.rs |
Modified to accept builtin extensions and use them instead of goose_mcp::BUILTIN_EXTENSIONS |
crates/goose/src/execution/manager.rs |
Added builtin_extensions parameter to AgentManager with new initialization methods |
crates/goose/Cargo.toml |
Removed dependency on goose-mcp |
crates/goose-mcp/src/lib.rs |
Re-exports types from goose::builtin_extension and continues to define BUILTIN_EXTENSIONS |
crates/goose-mcp/Cargo.toml |
Added dependency on goose |
crates/goose-server/src/state.rs |
Uses initialize_with_builtin_extensions() to pass builtin extensions |
crates/goose-cli/src/scenario_tests/scenario_runner.rs |
Passes goose_mcp::BUILTIN_EXTENSIONS.clone() to AgentConfig |
crates/goose-acp/src/server.rs |
Passes goose_mcp::BUILTIN_EXTENSIONS.clone() to AgentConfig |
crates/goose-acp/Cargo.toml |
Added explicit dependency on goose-mcp |
crates/goose/tests/agent.rs |
Updated all test call sites to pass HashMap::new() |
crates/goose/tests/mcp_integration_test.rs |
Updated test to pass HashMap::new() to ExtensionManager |
Cargo.lock |
Reflects dependency changes across crates |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
crates/goose/src/execution/manager.rs:85
- The
initialize()andinstance()methods are identical. Both use the sameAGENT_MANAGEROnceCell and have the same implementation. This duplication is unnecessary - you should either removeinitialize()and useinstance()everywhere, or clarify the semantic difference between them if one is intended.
pub async fn initialize() -> Result<Arc<Self>> {
AGENT_MANAGER
.get_or_try_init(|| async {
let max_sessions = Config::global()
.get_goose_max_active_agents()
.unwrap_or(DEFAULT_MAX_SESSION);
let schedule_file_path = Paths::data_dir().join("schedule.json");
let session_manager = Arc::new(SessionManager::instance());
let manager = Self::new(
session_manager,
schedule_file_path,
Some(max_sessions),
)
.await?;
Ok(Arc::new(manager))
})
.await
.cloned()
}
pub async fn instance() -> Result<Arc<Self>> {
AGENT_MANAGER
.get_or_try_init(|| async {
let max_sessions = Config::global()
.get_goose_max_active_agents()
.unwrap_or(DEFAULT_MAX_SESSION);
let schedule_file_path = Paths::data_dir().join("schedule.json");
let session_manager = Arc::new(SessionManager::instance());
let manager = Self::new(
session_manager,
schedule_file_path,
Some(max_sessions),
)
.await?;
Ok(Arc::new(manager))
})
.await
.cloned()
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
crates/goose/src/execution/manager.rs:77
initialize()duplicates the implementation ofinstance()byte-for-byte, which increases maintenance risk if one gets updated without the other; consider removinginitialize()and usinginstance(), or make one call the other so there’s a single initialization path.
pub async fn initialize() -> Result<Arc<Self>> {
AGENT_MANAGER
.get_or_try_init(|| async {
let max_sessions = Config::global()
.get_goose_max_active_agents()
.unwrap_or(DEFAULT_MAX_SESSION);
let schedule_file_path = Paths::data_dir().join("schedule.json");
let session_manager = Arc::new(SessionManager::instance());
let manager =
Self::new(session_manager, schedule_file_path, Some(max_sessions)).await?;
Ok(Arc::new(manager))
})
.await
.cloned()
}
pub async fn instance() -> Result<Arc<Self>> {
AGENT_MANAGER
.get_or_try_init(|| async {
let max_sessions = Config::global()
.get_goose_max_active_agents()
.unwrap_or(DEFAULT_MAX_SESSION);
let schedule_file_path = Paths::data_dir().join("schedule.json");
let session_manager = Arc::new(SessionManager::instance());
let manager =
Self::new(session_manager, schedule_file_path, Some(max_sessions)).await?;
Ok(Arc::new(manager))
})
.await
.cloned()
}
crates/goose/src/agents/extension_manager.rs:587
- With the new global registry, this "Unknown builtin extension" error can also happen when builtins were never registered in the process; consider amending the message to hint at calling
register_builtin_extensions(...)during startup to make misconfiguration easier to diagnose.
if !builtin_extensions.contains_key(name.as_str()) {
return Err(ExtensionError::ConfigError(format!(
"Unknown builtin extension: {}",
name
)));
| BUILTIN_REGISTRY.write().unwrap().insert(name, spawn_fn); | ||
| } | ||
|
|
||
| /// Register multiple builtin extensions from a HashMap | ||
| pub fn register_builtin_extensions(extensions: HashMap<&'static str, SpawnServerFn>) { | ||
| let mut registry = BUILTIN_REGISTRY.write().unwrap(); | ||
| registry.extend(extensions); | ||
| } | ||
|
|
||
| /// Get a copy of all registered builtin extensions | ||
| pub fn get_builtin_extensions() -> HashMap<&'static str, SpawnServerFn> { | ||
| BUILTIN_REGISTRY.read().unwrap().clone() |
There was a problem hiding this comment.
The unwrap() calls on the global RwLock will panic if the lock is ever poisoned; handle the PoisonError (e.g., by recovering with into_inner() or returning a Result) so a previous panic doesn’t take down the whole process when registering/reading builtins.
| BUILTIN_REGISTRY.write().unwrap().insert(name, spawn_fn); | |
| } | |
| /// Register multiple builtin extensions from a HashMap | |
| pub fn register_builtin_extensions(extensions: HashMap<&'static str, SpawnServerFn>) { | |
| let mut registry = BUILTIN_REGISTRY.write().unwrap(); | |
| registry.extend(extensions); | |
| } | |
| /// Get a copy of all registered builtin extensions | |
| pub fn get_builtin_extensions() -> HashMap<&'static str, SpawnServerFn> { | |
| BUILTIN_REGISTRY.read().unwrap().clone() | |
| BUILTIN_REGISTRY | |
| .write() | |
| .unwrap_or_else(|e| e.into_inner()) | |
| .insert(name, spawn_fn); | |
| } | |
| /// Register multiple builtin extensions from a HashMap | |
| pub fn register_builtin_extensions(extensions: HashMap<&'static str, SpawnServerFn>) { | |
| let mut registry = BUILTIN_REGISTRY | |
| .write() | |
| .unwrap_or_else(|e| e.into_inner()); | |
| registry.extend(extensions); | |
| } | |
| /// Get a copy of all registered builtin extensions | |
| pub fn get_builtin_extensions() -> HashMap<&'static str, SpawnServerFn> { | |
| BUILTIN_REGISTRY | |
| .read() | |
| .unwrap_or_else(|e| e.into_inner()) | |
| .clone() |
| /// Get a copy of all registered builtin extensions | ||
| pub fn get_builtin_extensions() -> HashMap<&'static str, SpawnServerFn> { | ||
| BUILTIN_REGISTRY.read().unwrap().clone() | ||
| } |
There was a problem hiding this comment.
get_builtin_extensions() clones the entire registry, which forces an allocation even when callers only need a lookup; consider adding has_builtin_extension(name: &str) / get_builtin_extension(name: &str) helpers that do the lookup under the read lock to avoid cloning the full HashMap.
| } | |
| } | |
| /// Check if a builtin extension with the given name is registered | |
| pub fn has_builtin_extension(name: &str) -> bool { | |
| BUILTIN_REGISTRY.read().unwrap().contains_key(name) | |
| } | |
| /// Get the builtin extension spawn function for the given name, if registered | |
| pub fn get_builtin_extension(name: &str) -> Option<SpawnServerFn> { | |
| BUILTIN_REGISTRY.read().unwrap().get(name).copied() | |
| } |
jh-block
left a comment
There was a problem hiding this comment.
nice improvement! two minor comments
| .await | ||
| .cloned() | ||
| } | ||
|
|
There was a problem hiding this comment.
This new initialize() method seems to be identical to the instance() method below. Could probably just use instance() wherever we're calling this?
| } | ||
|
|
||
| /// Get a copy of all registered builtin extensions | ||
| pub fn get_builtin_extensions() -> HashMap<&'static str, SpawnServerFn> { |
There was a problem hiding this comment.
How often is this called? Could potentially wrap the HashMap in an Arc (inside the RwLock) to make cloning it ~free, or provide a get_builtin_extension(name: &'static str) -> SpawnServerFn so that we don't have to return the whole registry.
| let timeout_duration = Duration::from_secs(timeout.unwrap_or(300)); | ||
| let normalized_name = name_to_key(name); | ||
|
|
||
| if !goose_mcp::BUILTIN_EXTENSIONS.contains_key(normalized_name.as_str()) { | ||
| return Err(ExtensionError::ConfigError(format!( | ||
| "Unknown builtin extension: {}", | ||
| name | ||
| ))); | ||
| } | ||
| let extension_fn = get_builtin_extension(name.as_str()).ok_or_else(|| { | ||
| ExtensionError::ConfigError(format!("Unknown builtin extension: {}", name)) | ||
| })?; |
There was a problem hiding this comment.
ExtensionConfig::Builtin lookup now uses the raw name, but builtin keys are normalized elsewhere (and previously used name_to_key), so configs like "Developer"/"developer" may regress; lookup should use the normalized key (or ensure registration uses the same normalization).
| registry.extend(extensions); | ||
| } | ||
|
|
||
| /// Get a copy of all registered builtin extensions |
There was a problem hiding this comment.
The doc comment says this returns “a copy of all registered builtin extensions”, but the function returns a single extension by name; update the comment to match the behavior.
| /// Get a copy of all registered builtin extensions | |
| /// Get a registered builtin extension by name |
9e45210 to
95dea77
Compare
| let _ = session.prompt(prompt, PermissionDecision::Cancel).await; | ||
|
|
||
| let result = fs::read_to_string("/tmp/result.txt").unwrap_or_default(); | ||
| eprintln!("{}", result); |
There was a problem hiding this comment.
This eprintln! will spam test output on success (and can leak fixture data in CI logs); remove it or only print the file contents when the assertion fails.
| eprintln!("{}", result); | |
| if !result.contains(FAKE_CODE) { | |
| eprintln!("{}", result); | |
| } |
| ExtensionConfig::Builtin { name, timeout, .. } => { | ||
| let timeout_duration = Duration::from_secs(timeout.unwrap_or(300)); | ||
| let normalized_name = name_to_key(name); | ||
|
|
||
| if !goose_mcp::BUILTIN_EXTENSIONS.contains_key(normalized_name.as_str()) { | ||
| return Err(ExtensionError::ConfigError(format!( | ||
| "Unknown builtin extension: {}", | ||
| name | ||
| ))); | ||
| } | ||
| let extension_fn = get_builtin_extension(name.as_str()).ok_or_else(|| { | ||
| ExtensionError::ConfigError(format!("Unknown builtin extension: {}", name)) | ||
| })?; |
There was a problem hiding this comment.
The builtin extension lookup no longer normalizes the configured name (unlike the previous name_to_key lookup), so configs like "Developer" or names containing spaces/hyphens that previously worked may now fail with "Unknown builtin extension"; compute normalized_name = name_to_key(name) once and use it for both registry lookup and the Docker goose mcp invocation.
This reverts commit 95dea77.
| static BUILTIN_REGISTRY: Lazy<RwLock<HashMap<&'static str, SpawnServerFn>>> = | ||
| Lazy::new(|| RwLock::new(HashMap::new())); |
There was a problem hiding this comment.
This module uses std::sync::RwLock but it’s accessed from async code paths (e.g., extension loading on Tokio); consider switching to tokio::sync::RwLock (or another async-friendly container) to avoid blocking executor threads.
…ntext * 'main' of github.com:block/goose: refactor(providers): extract ProviderDef trait and OpenAiCompatibleProvider (#6832) feat: ask ai discord bot (#6842) chore(maintenance): make GitHub repo configurable for auto-updater and publisher (#6828) fix: make apps work in built copies of goose (#6901) Remove dependency on goose-mcp from goose crate (#6637) Clean up build canonical warnings (#6880) Sync desktop_prompt with UI (#6898)
Signed-off-by: Harrison <hcstebbins@gmail.com>
goose-mcp contains all of our "built-in" mcp servers. At present we do compile these into the main goose binary (goose-server and goose-cli), but better to keep the goose crate itself independent, as other binaries are built on it that don't need to drag in all of the various developer/analysis tool dependencies.