Goose Tool Registry and Filtering Mechanism: Dynamic Extension Management

Goose implements a dynamic tool registry that aggregates tools from MCP extensions, applies extension-specific prefixes for global uniqueness, and provides filtering APIs to include or exclude specific extension tools.

The block/goose repository defines a flexible architecture for managing AI tool capabilities. Rather than maintaining a static, monolithic tool list, Goose dynamically constructs its tool registry by aggregating capabilities from every loaded Model Context Protocol (MCP) extension, applying intelligent prefixing and ownership metadata to prevent naming collisions.

Core Architecture of the Tool Registry

Extension Manager and Discovery

The registry centers on ExtensionManager in crates/goose/src/agents/extension_manager.rs. This component discovers, creates, caches, and invalidates the global tool list coming from all extensions. When an extension registers, Goose stores a McpClientBox capable of reporting its available tools via the MCP protocol.

Dynamic Tool Aggregation

ExtensionManager::fetch_all_tools iterates over every loaded extension, calls its MCP list_tools RPC (handling pagination automatically), and rewrites each tool name to ensure global uniqueness:

let public_name = if expose_unprefixed {
    tool.name.to_string()
} else {
    format!("{}__{}", name, tool.name)   // <ext>__<tool>
};
tool.name = public_name.into();

Each tool is enriched with a meta field (goose_extension via TOOL_EXTENSION_META_KEY) that records its owning extension. The complete vector is stored in tools_cache alongside a version counter, allowing subsequent calls to hit the cache unless the registry has been invalidated.

Tool Name Prefixing and Ownership

The Prefixing Strategy

Unless an extension explicitly opts out via expose_unprefixed, Goose prefixes every tool with its extension name and a double underscore separator (<ext>__). This guarantees that tools with identical names from different extensions remain distinguishable in the global registry.

Ownership Detection

When filtering tools, ExtensionManager::filter_tools canonicalizes names using the name_to_key helper (defined in the utils module) to handle case insensitivity and hyphen/underscore normalization. Ownership is determined by reading the goose_extension meta field, or inferred from the prefix if metadata is absent:

let tool_owner = get_tool_owner(tool)
    .map(|s| name_to_key(&s))
    .unwrap_or_else(|| tool.name.split("__").next().unwrap_or("").to_string());

Filtering Mechanisms

Extension-Based Filtering

The registry exposes three access patterns via Agent::list_tools in crates/goose/src/agents/agent.rs:

  • All tools: list_tools(session_id, None) returns the complete prefixed set via ExtensionManager::get_prefixed_tools
  • Specific extension: list_tools(session_id, Some("extension_name")) filters to tools owned by that extension
  • Excluding extensions: ExtensionManager::get_prefixed_tools_excluding(session_id, "extension_name") returns all tools except those from the specified extension

The filtering logic applies inclusion or exclusion rules after normalizing both the tool owner and filter arguments:

if let Some(ref excluded) = exclude_normalized {
    if tool_owner == *excluded { return false; }
}
if let Some(ref name_filter) = extension_name_normalized {
    tool_owner == *name_filter
} else {
    true
}

Hidden Extensions

Extensions whose names start with an underscore (_) are considered hidden and never exposed to end-users. The is_hidden_extension helper (lines 225-229 in extension_manager.rs) filters these during tool enumeration.

Caching and Invalidation

The registry maintains a versioned cache to optimize performance. When extensions are added or removed, ExtensionManager::invalidate_tools_cache_and_bump_version clears the cache and increments the version counter. This forces the next fetch_all_tools call to rebuild the registry from scratch, ensuring the tool list reflects the current extension state.

Accessing the Registry Programmatically

List every tool available to a session:

let agent = GooseAgent::new(...).await?;
let tools = agent.list_tools("session-123", None).await;
for t in tools {
    println!("{} – {}", t.name, t.description);
}

Retrieve only tools from a specific extension:

let github_tools = agent.list_tools("session-123", Some("github".into())).await;

Exclude a specific extension when building prompts:

let all_tools = agent.extension_manager
    .get_prefixed_tools_excluding("session-123", "developer")
    .await?;
let tool_infos: Vec<ToolInfo> = all_tools.iter()
    .map(|t| ToolInfo::new(&t.name, &t.description, get_parameter_names(t), None))
    .collect();

Force a registry refresh after loading new extensions:

agent.extension_manager
    .invalidate_tools_cache_and_bump_version().await;
let refreshed = agent.list_tools("session-123", None).await;

Summary

  • Goose builds a dynamic tool registry by aggregating MCP extension capabilities at runtime rather than using static definitions
  • Extension-specific prefixes (<ext>__<tool>) ensure global uniqueness and prevent naming collisions
  • Metadata tagging (goose_extension) tracks tool ownership for reliable filtering and attribution
  • Normalized name matching via name_to_key handles case insensitivity and punctuation variations consistently
  • Versioned caching optimizes performance while allowing dynamic extension updates without restarts
  • Hidden extension support (names starting with _) isolates internal tools from user-facing interfaces

Frequently Asked Questions

How does Goose prevent tool name collisions between extensions?

Goose automatically prefixes tool names with their extension name and a double underscore separator (<ext>__<tool>) unless the extension explicitly opts out via expose_unprefixed. This ensures every tool has a globally unique identifier within the registry, preventing collisions when multiple extensions define tools with the same base name.

How does the filtering mechanism determine which extension owns a tool?

The system first checks the goose_extension meta field stored in the tool's metadata. If this field is absent, ownership is inferred by parsing the tool name itself—splitting on __ and extracting the prefix. Both the extracted owner and user-provided filter arguments are normalized using name_to_key to handle case insensitivity and punctuation variations like hyphens versus underscores.

Can I dynamically refresh the tool registry without restarting Goose?

Yes. Call ExtensionManager::invalidate_tools_cache_and_bump_version() to clear the internal cache and increment the version counter. The next call to list_tools or fetch_all_tools will automatically rebuild the registry, picking up any newly loaded extensions or removing tools from unloaded ones.

What happens to extensions prefixed with an underscore?

Extensions whose names start with _ are treated as hidden extensions. The is_hidden_extension helper function filters these out during tool enumeration, ensuring that internal, experimental, or system extensions never appear in user-facing tool lists or CLI completions.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →