Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Added

- Added `--disable-builtin-tools` flag to the `serve` command that allows disabling all built-in tools (load-component, unload-component, list-components, get-policy, grant/revoke permissions, search-components, reset-permission). When enabled, only loaded component tools will be available through the MCP server
- AI agent development guides (`AGENTS.md` and `Claude.md`) that consolidate development guidelines from `.github/instructions/` into accessible documentation for AI agents working on the project
- Comprehensive installation guide page consolidating all installation methods (one-liner script, Homebrew, Nix, WinGet) organized by platform (Linux, macOS, Windows) with verification steps and troubleshooting sections
- Cookbook section in documentation with language-specific guides for building Wasm components in JavaScript/TypeScript, Python, Rust, and Go ([#328](https://github.com/microsoft/wassette/pull/328))
Expand Down
97 changes: 70 additions & 27 deletions crates/mcp-server/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ const COMPONENT_LIST: &str = include_str!("../../../component-registry.json");

/// Handles a request to list available tools.
#[instrument(skip(lifecycle_manager))]
pub async fn handle_tools_list(lifecycle_manager: &LifecycleManager) -> Result<Value> {
pub async fn handle_tools_list(
lifecycle_manager: &LifecycleManager,
disable_builtin_tools: bool,
) -> Result<Value> {
debug!("Handling tools list request");

let mut tools = get_component_tools(lifecycle_manager).await?;
tools.extend(get_builtin_tools());
if !disable_builtin_tools {
tools.extend(get_builtin_tools());
}
debug!(num_tools = %tools.len(), "Retrieved tools");

let response = rmcp::model::ListToolsResult {
Expand All @@ -36,41 +41,79 @@ pub async fn handle_tools_list(lifecycle_manager: &LifecycleManager) -> Result<V
Ok(serde_json::to_value(response)?)
}

/// Check if a tool name is a builtin tool
fn is_builtin_tool(name: &str) -> bool {
matches!(
name,
"load-component"
| "unload-component"
| "list-components"
| "get-policy"
| "grant-storage-permission"
| "grant-network-permission"
| "grant-environment-variable-permission"
| "revoke-storage-permission"
| "revoke-network-permission"
| "revoke-environment-variable-permission"
| "search-components"
| "reset-permission"
)
}

/// Handles a tool call request.
#[instrument(skip_all, fields(method_name = %req.name))]
pub async fn handle_tools_call(
req: CallToolRequestParam,
lifecycle_manager: &LifecycleManager,
server_peer: Peer<RoleServer>,
disable_builtin_tools: bool,
) -> Result<Value> {
info!("Handling tool call");

let result = match req.name.as_ref() {
"load-component" => handle_load_component(&req, lifecycle_manager, server_peer).await,
"unload-component" => handle_unload_component(&req, lifecycle_manager, server_peer).await,
"list-components" => handle_list_components(lifecycle_manager).await,
"get-policy" => handle_get_policy(&req, lifecycle_manager).await,
"grant-storage-permission" => {
handle_grant_storage_permission(&req, lifecycle_manager).await
}
"grant-network-permission" => {
handle_grant_network_permission(&req, lifecycle_manager).await
}
"grant-environment-variable-permission" => {
handle_grant_environment_variable_permission(&req, lifecycle_manager).await
}
"revoke-storage-permission" => {
handle_revoke_storage_permission(&req, lifecycle_manager).await
}
"revoke-network-permission" => {
handle_revoke_network_permission(&req, lifecycle_manager).await
}
"revoke-environment-variable-permission" => {
handle_revoke_environment_variable_permission(&req, lifecycle_manager).await
let result = if disable_builtin_tools && is_builtin_tool(req.name.as_ref()) {
// When builtin tools are disabled, reject calls to builtin tools
Err(anyhow::anyhow!("Built-in tools are disabled"))
} else {
// Handle builtin tools (if enabled) or component calls
match req.name.as_ref() {
"load-component" if !disable_builtin_tools => {
handle_load_component(&req, lifecycle_manager, server_peer).await
}
"unload-component" if !disable_builtin_tools => {
handle_unload_component(&req, lifecycle_manager, server_peer).await
}
"list-components" if !disable_builtin_tools => {
handle_list_components(lifecycle_manager).await
}
"get-policy" if !disable_builtin_tools => {
handle_get_policy(&req, lifecycle_manager).await
}
"grant-storage-permission" if !disable_builtin_tools => {
handle_grant_storage_permission(&req, lifecycle_manager).await
}
"grant-network-permission" if !disable_builtin_tools => {
handle_grant_network_permission(&req, lifecycle_manager).await
}
"grant-environment-variable-permission" if !disable_builtin_tools => {
handle_grant_environment_variable_permission(&req, lifecycle_manager).await
}
"revoke-storage-permission" if !disable_builtin_tools => {
handle_revoke_storage_permission(&req, lifecycle_manager).await
}
"revoke-network-permission" if !disable_builtin_tools => {
handle_revoke_network_permission(&req, lifecycle_manager).await
}
"revoke-environment-variable-permission" if !disable_builtin_tools => {
handle_revoke_environment_variable_permission(&req, lifecycle_manager).await
}
"search-components" if !disable_builtin_tools => {
handle_search_component(&req, lifecycle_manager).await
}
"reset-permission" if !disable_builtin_tools => {
handle_reset_permission(&req, lifecycle_manager).await
}
_ => handle_component_call(&req, lifecycle_manager).await,
}
Comment on lines +73 to 116
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic is duplicated - builtin tools are checked twice (first in the if condition, then in each match arm guard). Consider simplifying by removing the guards from the match arms since builtin tools are already handled in the outer if condition.

Copilot uses AI. Check for mistakes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

"search-components" => handle_search_component(&req, lifecycle_manager).await,
"reset-permission" => handle_reset_permission(&req, lifecycle_manager).await,
_ => handle_component_call(&req, lifecycle_manager).await,
};

if let Err(ref e) = result {
Expand Down
5 changes: 5 additions & 0 deletions src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ pub struct Serve {
#[arg(long = "env-file")]
#[serde(skip)]
pub env_file: Option<PathBuf>,

/// Disable built-in tools (load-component, unload-component, list-components, etc.)
#[arg(long)]
#[serde(default)]
pub disable_builtin_tools: bool,
}

#[derive(Args, Debug, Clone, Serialize, Deserialize, Default)]
Expand Down
2 changes: 2 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ mod tests {
transport: Default::default(),
env_vars: vec![],
env_file: None,
disable_builtin_tools: false,
}
}

Expand All @@ -143,6 +144,7 @@ mod tests {
transport: Default::default(),
env_vars: vec![],
env_file: None,
disable_builtin_tools: false,
}
}

Expand Down
20 changes: 16 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ const BIND_ADDRESS: &str = "127.0.0.1:9001";
pub struct McpServer {
lifecycle_manager: LifecycleManager,
peer: Arc<Mutex<Option<rmcp::Peer<rmcp::RoleServer>>>>,
disable_builtin_tools: bool,
}

/// Handle CLI tool commands by creating appropriate tool call requests
Expand Down Expand Up @@ -271,6 +272,7 @@ async fn create_lifecycle_manager(plugin_dir: Option<PathBuf>) -> Result<Lifecyc
transport: Default::default(),
env_vars: vec![],
env_file: None,
disable_builtin_tools: false,
})
.context("Failed to load configuration")?
};
Expand All @@ -297,10 +299,12 @@ impl McpServer {
///
/// # Arguments
/// * `lifecycle_manager` - The lifecycle manager for handling component operations
pub fn new(lifecycle_manager: LifecycleManager) -> Self {
/// * `disable_builtin_tools` - Whether to disable built-in tools
pub fn new(lifecycle_manager: LifecycleManager, disable_builtin_tools: bool) -> Self {
Self {
lifecycle_manager,
peer: Arc::new(Mutex::new(None)),
disable_builtin_tools,
}
}

Expand Down Expand Up @@ -354,8 +358,15 @@ Key points:
// Store peer on first request
self.store_peer_if_empty(peer_clone.clone());

let disable_builtin_tools = self.disable_builtin_tools;
Box::pin(async move {
let result = handle_tools_call(params, &self.lifecycle_manager, peer_clone).await;
let result = handle_tools_call(
params,
&self.lifecycle_manager,
peer_clone,
disable_builtin_tools,
)
.await;
match result {
Ok(value) => serde_json::from_value(value).map_err(|e| {
ErrorData::parse_error(format!("Failed to parse result: {e}"), None)
Expand All @@ -373,8 +384,9 @@ Key points:
// Store peer on first request
self.store_peer_if_empty(ctx.peer.clone());

let disable_builtin_tools = self.disable_builtin_tools;
Box::pin(async move {
let result = handle_tools_list(&self.lifecycle_manager).await;
let result = handle_tools_list(&self.lifecycle_manager, disable_builtin_tools).await;
match result {
Ok(value) => serde_json::from_value(value).map_err(|e| {
ErrorData::parse_error(format!("Failed to parse result: {e}"), None)
Expand Down Expand Up @@ -519,7 +531,7 @@ async fn main() -> Result<()> {
.build()
.await?;

let server = McpServer::new(lifecycle_manager.clone());
let server = McpServer::new(lifecycle_manager.clone(), cfg.disable_builtin_tools);

// Start background component loading
let server_clone = server.clone();
Expand Down
161 changes: 161 additions & 0 deletions tests/transport_integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1002,3 +1002,164 @@ async fn test_grant_permission_network_basic() -> Result<()> {

Ok(())
}

#[test(tokio::test)]
Copy link

Copilot AI Oct 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test attribute should be #[tokio::test] not #[test(tokio::test)]. The current syntax is incorrect and may not work as expected.

Copilot uses AI. Check for mistakes.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

async fn test_disable_builtin_tools() -> Result<()> {
// Create a temporary directory for this test to avoid loading existing components
let temp_dir = tempfile::tempdir()?;
let plugin_dir_arg = format!("--plugin-dir={}", temp_dir.path().display());

// Get the path to the built binary
let binary_path = std::env::current_dir()
.context("Failed to get current directory")?
.join("target/debug/wassette");

// Start the server with stdio transport and disable-builtin-tools flag
let mut child = tokio::process::Command::new(&binary_path)
.args(["serve", &plugin_dir_arg, "--disable-builtin-tools"])
.env("RUST_LOG", "off")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("Failed to start wassette with disabled builtin tools")?;

let stdin = child.stdin.take().context("Failed to get stdin handle")?;
let stdout = child.stdout.take().context("Failed to get stdout handle")?;
let stderr = child.stderr.take().context("Failed to get stderr handle")?;

let mut stdin = stdin;
let mut stdout = BufReader::new(stdout);
let mut stderr = BufReader::new(stderr);

// Give the server time to start
tokio::time::sleep(Duration::from_millis(1000)).await;

// Check if the process is still running
if let Ok(Some(status)) = child.try_wait() {
let mut stderr_output = String::new();
let _ = stderr.read_line(&mut stderr_output).await;
return Err(anyhow::anyhow!(
"Server process exited with status: {:?}, stderr: {}",
status,
stderr_output
));
}

// Send MCP initialize request
let initialize_request = r#"{"jsonrpc": "2.0", "method": "initialize", "params": {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "test-client", "version": "1.0.0"}}, "id": 1}
"#;

stdin.write_all(initialize_request.as_bytes()).await?;
stdin.flush().await?;

// Read and verify response
let mut response_line = String::new();
tokio::time::timeout(
Duration::from_secs(10),
stdout.read_line(&mut response_line),
)
.await
.context("Timeout waiting for initialize response")?
.context("Failed to read initialize response")?;

let response: serde_json::Value =
serde_json::from_str(&response_line).context("Failed to parse initialize response")?;

assert_eq!(response["jsonrpc"], "2.0");
assert_eq!(response["id"], 1);
assert!(response["result"].is_object());

// Send initialized notification
let initialized_notification = r#"{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
"#;

stdin.write_all(initialized_notification.as_bytes()).await?;
stdin.flush().await?;

// Send list_tools request
let list_tools_request = r#"{"jsonrpc": "2.0", "method": "tools/list", "params": {}, "id": 2}
"#;

stdin.write_all(list_tools_request.as_bytes()).await?;
stdin.flush().await?;

// Read and verify tools list response
let mut tools_response_line = String::new();
tokio::time::timeout(
Duration::from_secs(10),
stdout.read_line(&mut tools_response_line),
)
.await
.context("Timeout waiting for tools/list response")?
.context("Failed to read tools/list response")?;

let tools_response: serde_json::Value = serde_json::from_str(&tools_response_line)
.context("Failed to parse tools/list response")?;

// Verify the tools response structure
assert_eq!(tools_response["jsonrpc"], "2.0");
assert_eq!(tools_response["id"], 2);
assert!(tools_response["result"].is_object());
assert!(tools_response["result"]["tools"].is_array());

// Verify that built-in tools are NOT present when disabled
let tools = &tools_response["result"]["tools"].as_array().unwrap();
let tool_names: Vec<String> = tools
.iter()
.map(|tool| tool["name"].as_str().unwrap_or("").to_string())
.collect();

assert!(
!tool_names.contains(&"load-component".to_string()),
"load-component should not be present when builtin tools are disabled"
);
assert!(
!tool_names.contains(&"unload-component".to_string()),
"unload-component should not be present when builtin tools are disabled"
);
assert!(
!tool_names.contains(&"list-components".to_string()),
"list-components should not be present when builtin tools are disabled"
);
assert!(
!tool_names.contains(&"get-policy".to_string()),
"get-policy should not be present when builtin tools are disabled"
);

// Try to call a builtin tool and verify it fails
let call_tool_request = r#"{"jsonrpc": "2.0", "method": "tools/call", "params": {"name": "list-components", "arguments": {}}, "id": 3}
"#;

stdin.write_all(call_tool_request.as_bytes()).await?;
stdin.flush().await?;

// Read and verify call tool response
let mut call_response_line = String::new();
tokio::time::timeout(
Duration::from_secs(10),
stdout.read_line(&mut call_response_line),
)
.await
.context("Timeout waiting for tools/call response")?
.context("Failed to read tools/call response")?;

let call_response: serde_json::Value =
serde_json::from_str(&call_response_line).context("Failed to parse tools/call response")?;

// Verify that the tool call failed
assert_eq!(call_response["jsonrpc"], "2.0");
assert_eq!(call_response["id"], 3);
assert!(call_response["result"].is_object());
let result = &call_response["result"];
assert_eq!(
result["isError"].as_bool().unwrap_or(false),
true,
"Tool call should have failed"
);

// Clean up
child.kill().await.ok();

Ok(())
}
Loading