From 9fea40d7a7c32c81c5f0f24325045647f51e37af Mon Sep 17 00:00:00 2001 From: Daniel Hodges Date: Fri, 9 Jan 2026 07:34:40 -0800 Subject: [PATCH] Add MCP server for AI agent integration with Buck2 Implements a Model Context Protocol (MCP) server that allows AI agents to interact with Buck2 through a JSON-RPC interface over stdio. Features: - MCP protocol implementation with JSON-RPC 2.0 transport - Daemon client for connecting to running Buck2 daemon - Tools: buck2_build, buck2_query (cquery/uquery), buck2_targets - Async build support with operation polling for long-running builds - Operation manager for tracking and cancelling async operations Signed-off-by: Daniel Hodges --- Cargo.toml | 2 + app/buck2_mcp/BUCK | 44 +++ app/buck2_mcp/Cargo.toml | 35 ++ app/buck2_mcp/src/bin/buck2-mcp.rs | 43 ++ app/buck2_mcp/src/daemon.rs | 13 + app/buck2_mcp/src/daemon/client.rs | 418 ++++++++++++++++++++ app/buck2_mcp/src/lib.rs | 20 + app/buck2_mcp/src/mcp.rs | 16 + app/buck2_mcp/src/mcp/error.rs | 84 ++++ app/buck2_mcp/src/mcp/protocol.rs | 461 ++++++++++++++++++++++ app/buck2_mcp/src/mcp/server.rs | 212 ++++++++++ app/buck2_mcp/src/mcp/transport.rs | 66 ++++ app/buck2_mcp/src/operations.rs | 14 + app/buck2_mcp/src/operations/manager.rs | 266 +++++++++++++ app/buck2_mcp/src/operations/operation.rs | 110 ++++++ app/buck2_mcp/src/tools.rs | 21 + app/buck2_mcp/src/tools/build.rs | 86 ++++ app/buck2_mcp/src/tools/build_async.rs | 136 +++++++ app/buck2_mcp/src/tools/operations.rs | 253 ++++++++++++ app/buck2_mcp/src/tools/query.rs | 121 ++++++ app/buck2_mcp/src/tools/registry.rs | 88 +++++ app/buck2_mcp/src/tools/targets.rs | 90 +++++ 22 files changed, 2599 insertions(+) create mode 100644 app/buck2_mcp/BUCK create mode 100644 app/buck2_mcp/Cargo.toml create mode 100644 app/buck2_mcp/src/bin/buck2-mcp.rs create mode 100644 app/buck2_mcp/src/daemon.rs create mode 100644 app/buck2_mcp/src/daemon/client.rs create mode 100644 app/buck2_mcp/src/lib.rs create mode 100644 app/buck2_mcp/src/mcp.rs create mode 100644 app/buck2_mcp/src/mcp/error.rs create mode 100644 app/buck2_mcp/src/mcp/protocol.rs create mode 100644 app/buck2_mcp/src/mcp/server.rs create mode 100644 app/buck2_mcp/src/mcp/transport.rs create mode 100644 app/buck2_mcp/src/operations.rs create mode 100644 app/buck2_mcp/src/operations/manager.rs create mode 100644 app/buck2_mcp/src/operations/operation.rs create mode 100644 app/buck2_mcp/src/tools.rs create mode 100644 app/buck2_mcp/src/tools/build.rs create mode 100644 app/buck2_mcp/src/tools/build_async.rs create mode 100644 app/buck2_mcp/src/tools/operations.rs create mode 100644 app/buck2_mcp/src/tools/query.rs create mode 100644 app/buck2_mcp/src/tools/registry.rs create mode 100644 app/buck2_mcp/src/tools/targets.rs diff --git a/Cargo.toml b/Cargo.toml index 8021e77ea46b..0f8c95f0af00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ members = [ "app/buck2_grpc", "app/buck2_health_check", "app/buck2_health_check_proto", + "app/buck2_mcp", "app/buck2_http", "app/buck2_install_proto", "app/buck2_interpreter", @@ -434,6 +435,7 @@ buck2_host_sharing_proto = { path = "app/buck2_host_sharing_proto" } buck2_http = { path = "app/buck2_http" } buck2_install_proto = { path = "app/buck2_install_proto" } buck2_interpreter = { path = "app/buck2_interpreter" } +buck2_mcp = { path = "app/buck2_mcp" } buck2_interpreter_for_build = { path = "app/buck2_interpreter_for_build" } buck2_interpreter_for_build_tests = { path = "app/buck2_interpreter_for_build_tests" } buck2_miniperf = { path = "app/buck2_miniperf" } diff --git a/app/buck2_mcp/BUCK b/app/buck2_mcp/BUCK new file mode 100644 index 000000000000..8896f9cabc1b --- /dev/null +++ b/app/buck2_mcp/BUCK @@ -0,0 +1,44 @@ +load("@fbcode//buck2:buck_rust_binary.bzl", "buck_rust_binary") +load("@fbsource//tools/build_defs:rust_library.bzl", "rust_library") + +oncall("build_infra") + +rust_library( + name = "buck2_mcp", + srcs = glob([ + "src/**/*.rs", + ]), + test_deps = [ + "fbsource//third-party/rust:tempfile", + ], + deps = [ + "fbsource//third-party/rust:async-trait", + "fbsource//third-party/rust:chrono", + "fbsource//third-party/rust:dirs", + "fbsource//third-party/rust:futures", + "fbsource//third-party/rust:serde", + "fbsource//third-party/rust:serde_json", + "fbsource//third-party/rust:tokio", + "fbsource//third-party/rust:tonic-0-12-3", + "fbsource//third-party/rust:tracing", + "fbsource//third-party/rust:uuid", + "//buck2/app/buck2_cli_proto:buck2_cli_proto", + "//buck2/app/buck2_common:buck2_common", + "//buck2/app/buck2_error:buck2_error", + ], +) + +buck_rust_binary( + name = "buck2-mcp", + srcs = ["src/bin/buck2-mcp.rs"], + crate = "buck2_mcp_bin", + crate_root = "src/bin/buck2-mcp.rs", + deps = [ + ":buck2_mcp", + "fbsource//third-party/rust:tokio", + "fbsource//third-party/rust:tracing", + "fbsource//third-party/rust:tracing-subscriber", + "//buck2/app/buck2_error:buck2_error", + "//buck2/app/buck2_fs:buck2_fs", + ], +) diff --git a/app/buck2_mcp/Cargo.toml b/app/buck2_mcp/Cargo.toml new file mode 100644 index 000000000000..53749f80ee31 --- /dev/null +++ b/app/buck2_mcp/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "buck2_mcp" +description = "Buck2 MCP (Model Context Protocol) server for AI agent integration" +edition = "2024" +license = { workspace = true } +repository = { workspace = true } +version = "0.1.0" + +[[bin]] +name = "buck2-mcp" +path = "src/bin/buck2-mcp.rs" + +[dependencies] +async-trait = { workspace = true } +chrono = { workspace = true } +dirs = { workspace = true } +futures = { workspace = true } +prost = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +uuid = { workspace = true } + +buck2_cli_proto = { workspace = true } +buck2_common = { workspace = true } +buck2_core = { workspace = true } +buck2_error = { workspace = true } +buck2_fs = { workspace = true } +buck2_util = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/app/buck2_mcp/src/bin/buck2-mcp.rs b/app/buck2_mcp/src/bin/buck2-mcp.rs new file mode 100644 index 000000000000..b8741b53f6cc --- /dev/null +++ b/app/buck2_mcp/src/bin/buck2-mcp.rs @@ -0,0 +1,43 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Buck2 MCP server binary entry point. + +use std::env; + +use tracing::info; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> buck2_error::Result<()> { + // Initialize tracing to stderr (stdout is used for MCP protocol) + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .with_writer(std::io::stderr) + .init(); + + info!("Starting Buck2 MCP server"); + + let working_dir = env::current_dir() + .map_err(|e| { + buck2_error::buck2_error!( + buck2_error::ErrorTag::Environment, + "Failed to get current directory: {}", + e + ) + })? + .to_string_lossy() + .to_string(); + + info!("Working directory: {}", working_dir); + + let mut server = buck2_mcp::mcp::server::McpServer::new(working_dir); + server.run().await +} diff --git a/app/buck2_mcp/src/daemon.rs b/app/buck2_mcp/src/daemon.rs new file mode 100644 index 000000000000..a68e774f9826 --- /dev/null +++ b/app/buck2_mcp/src/daemon.rs @@ -0,0 +1,13 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Buck2 daemon client wrapper. + +pub mod client; diff --git a/app/buck2_mcp/src/daemon/client.rs b/app/buck2_mcp/src/daemon/client.rs new file mode 100644 index 000000000000..9da38be2edef --- /dev/null +++ b/app/buck2_mcp/src/daemon/client.rs @@ -0,0 +1,418 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Buck2 daemon client for MCP server. +//! +//! Provides a simplified interface to communicate with the Buck2 daemon +//! for running queries, builds, and other commands. + +use std::fs::File; +use std::io::BufReader; +use std::net::Ipv4Addr; + +use buck2_cli_proto::BuildRequest; +use buck2_cli_proto::ClientContext; +use buck2_cli_proto::CqueryRequest; +use buck2_cli_proto::DaemonProcessInfo; +use buck2_cli_proto::MultiCommandProgress; +use buck2_cli_proto::QueryOutputFormat; +use buck2_cli_proto::TargetCfg; +use buck2_cli_proto::TargetsRequest; +use buck2_cli_proto::UqueryRequest; +use buck2_cli_proto::daemon_api_client::DaemonApiClient; +use buck2_common::buckd_connection::BUCK_AUTH_TOKEN_HEADER; +use buck2_common::buckd_connection::ConnectionType; +use buck2_common::client_utils::get_channel_tcp; +use buck2_error::BuckErrorContext; +use futures::StreamExt; +use tonic::Request; +use tonic::Status; +use tonic::codegen::InterceptedService; +use tonic::metadata::AsciiMetadataValue; +use tonic::service::Interceptor; +use tonic::transport::Channel; +use tracing::debug; + +use crate::mcp::error::McpError; + +/// Auth token interceptor for Buck2 daemon. +#[derive(Clone)] +struct AuthInterceptor { + auth_token: AsciiMetadataValue, +} + +impl Interceptor for AuthInterceptor { + fn call(&mut self, mut request: Request<()>) -> Result, Status> { + request + .metadata_mut() + .append(BUCK_AUTH_TOKEN_HEADER, self.auth_token.clone()); + Ok(request) + } +} + +/// Simplified daemon client for MCP server. +#[derive(Clone)] +pub struct McpDaemonClient { + client: DaemonApiClient>, + working_dir: String, +} + +impl McpDaemonClient { + /// Connect to the Buck2 daemon. + /// + /// Looks for the daemon info file in the standard location and establishes + /// a gRPC connection. + pub async fn connect(working_dir: &str) -> buck2_error::Result { + // Find the daemon info file + let daemon_info = Self::find_daemon_info(working_dir).await?; + + debug!( + "Connecting to Buck2 daemon at {} (pid {})", + daemon_info.endpoint, daemon_info.pid + ); + + let connection_type = ConnectionType::parse(&daemon_info.endpoint)?; + + let channel = Self::create_channel(connection_type).await?; + + let auth_token = AsciiMetadataValue::try_from(daemon_info.auth_token).map_err(|e| { + buck2_error::buck2_error!(buck2_error::ErrorTag::Input, "Invalid auth token: {}", e) + })?; + + let client = DaemonApiClient::with_interceptor(channel, AuthInterceptor { auth_token }) + .max_encoding_message_size(usize::MAX) + .max_decoding_message_size(usize::MAX); + + Ok(Self { + client, + working_dir: working_dir.to_owned(), + }) + } + + /// Find and load the daemon info file. + async fn find_daemon_info(working_dir: &str) -> buck2_error::Result { + // Try to find .buckd directory starting from working_dir + // The daemon info is typically at ~/.buck/buckd//v2/buckd.info + // or in the project's .buckd directory + + let home = dirs::home_dir().buck_error_context("Could not find home directory")?; + let working_path = std::path::Path::new(working_dir); + let possible_paths = Self::get_possible_daemon_paths(&home, working_path)?; + + for path in possible_paths { + debug!("Checking for daemon info at {:?}", path); + if path.exists() { + let file = File::open(&path).buck_error_context("Failed to open buckd.info")?; + let reader = BufReader::new(file); + let info: DaemonProcessInfo = serde_json::from_reader(reader) + .buck_error_context("Failed to parse buckd.info")?; + return Ok(info); + } + } + + Err(McpError::DaemonConnection( + "Buck2 daemon not found. Please start it with 'buck2 status' or any buck2 command." + .to_owned(), + ) + .into()) + } + + /// Get possible paths where the daemon info file might be located. + fn get_possible_daemon_paths( + home: &std::path::Path, + working_path: &std::path::Path, + ) -> buck2_error::Result> { + let mut paths = Vec::new(); + + // Standard Buck2 daemon directory structure: + // ~/.buck/buckd//v2/buckd.info + + let buck_dir = home.join(".buck").join("buckd"); + if buck_dir.exists() + && let Ok(entries) = std::fs::read_dir(&buck_dir) + { + for entry in entries.flatten() { + let v2_path = entry.path().join("v2").join("buckd.info"); + if v2_path.exists() { + paths.push(v2_path); + } + } + } + + let local_buckd = working_path.join(".buckd").join("buckd.info"); + if local_buckd.exists() { + paths.push(local_buckd); + } + + let mut current = working_path; + while let Some(parent) = current.parent() { + let buckd_info = parent.join(".buckd").join("buckd.info"); + if buckd_info.exists() { + paths.push(buckd_info); + } + current = parent; + } + + Ok(paths) + } + + /// Create a gRPC channel to the daemon. + async fn create_channel(connection_type: ConnectionType) -> buck2_error::Result { + match connection_type { + ConnectionType::Tcp { port } => Ok(get_channel_tcp(Ipv4Addr::LOCALHOST, port).await?), + ConnectionType::Uds { unix_socket: _ } => { + // Unix socket support - for now just use TCP + // In a full implementation, we'd use get_channel_uds + Err(buck2_error::buck2_error!( + buck2_error::ErrorTag::Environment, + "Unix socket connections not yet implemented in MCP client" + )) + } + } + } + + /// Create a client context for daemon requests. + fn create_client_context(&self, command_name: &str) -> ClientContext { + ClientContext { + working_dir: self.working_dir.clone(), + config_overrides: vec![], + host_platform: 0, + host_arch: 0, + oncall: String::new(), + disable_starlark_types: false, + unstable_typecheck: false, + target_call_stacks: false, + skip_targets_with_duplicate_names: false, + trace_id: uuid::Uuid::new_v4().to_string(), + reuse_current_config: false, + daemon_uuid: None, + sanitized_argv: vec![], + argfiles: vec![], + buck2_hard_error: String::new(), + command_name: command_name.to_owned(), + client_metadata: vec![], + preemptible: 0, + host_xcode_version: None, + representative_config_flags: vec![], + exit_when: 0, + profile_pattern_opts: None, + } + } + + /// Execute a cquery and return results as a string. + pub async fn cquery( + &mut self, + query: &str, + target_platform: Option<&str>, + target_universe: Option<&[String]>, + output_attributes: Option<&[String]>, + ) -> buck2_error::Result { + let request = CqueryRequest { + context: Some(self.create_client_context("cquery")), + query: query.to_owned(), + query_args: vec![], + output_attributes: output_attributes.map(|a| a.to_vec()).unwrap_or_default(), + target_universe: target_universe.map(|u| u.to_vec()).unwrap_or_default(), + target_cfg: Some(TargetCfg { + target_platform: target_platform.unwrap_or_default().to_owned(), + cli_modifiers: vec![], + }), + show_providers: false, + unstable_output_format: QueryOutputFormat::Json as i32, + profile_mode: None, + profile_output: None, + }; + + let response = self.client.cquery(Request::new(request)).await?; + self.collect_streaming_output(response.into_inner()).await + } + + /// Execute a uquery and return results as a string. + pub async fn uquery( + &mut self, + query: &str, + output_attributes: Option<&[String]>, + ) -> buck2_error::Result { + let request = UqueryRequest { + context: Some(self.create_client_context("uquery")), + query: query.to_owned(), + query_args: vec![], + output_attributes: output_attributes.map(|a| a.to_vec()).unwrap_or_default(), + unstable_output_format: QueryOutputFormat::Json as i32, + }; + + let response = self.client.uquery(Request::new(request)).await?; + self.collect_streaming_output(response.into_inner()).await + } + + /// Execute a build and return results as a string. + pub async fn build( + &mut self, + targets: &[String], + target_platform: Option<&str>, + show_output: bool, + ) -> buck2_error::Result { + use buck2_cli_proto::build_request; + + let request = BuildRequest { + context: Some(self.create_client_context("build")), + target_patterns: targets.to_vec(), + target_cfg: Some(TargetCfg { + target_platform: target_platform.unwrap_or_default().to_owned(), + cli_modifiers: vec![], + }), + build_providers: Some(build_request::BuildProviders { + default_info: build_request::build_providers::Action::Build as i32, + run_info: build_request::build_providers::Action::BuildIfAvailable as i32, + test_info: build_request::build_providers::Action::Skip as i32, + }), + response_options: Some(build_request::ResponseOptions { + return_outputs: show_output, + ..Default::default() + }), + build_opts: Some(buck2_cli_proto::CommonBuildOptions::default()), + final_artifact_materializations: build_request::Materializations::Default as i32, + final_artifact_uploads: build_request::Uploads::Never as i32, + target_universe: vec![], + timeout: None, + }; + + let response = self.client.build(Request::new(request)).await?; + self.collect_build_output(response.into_inner()).await + } + + /// Execute a targets command and return results as a string. + pub async fn targets( + &mut self, + patterns: &[String], + output_attributes: Option<&[String]>, + format: &str, + ) -> buck2_error::Result { + use buck2_cli_proto::targets_request; + + let output_format = match format { + "json" => targets_request::OutputFormat::Json, + _ => targets_request::OutputFormat::Text, + }; + + let request = TargetsRequest { + context: Some(self.create_client_context("targets")), + target_patterns: patterns.to_vec(), + target_cfg: Some(TargetCfg::default()), + output: None, + output_format: output_format as i32, + targets: Some(targets_request::Targets::Other(targets_request::Other { + output_attributes: output_attributes.map(|a| a.to_vec()).unwrap_or_default(), + target_hash_graph_type: 0, + target_hash_file_mode: 0, + target_hash_modified_paths: vec![], + target_hash_use_fast_hash: true, + include_default_attributes: false, + target_hash_recursive: false, + keep_going: false, + streaming: false, + cached: true, + imports: false, + package_values: vec![], + })), + concurrency: None, + compression: 0, + }; + + let response = self.client.targets(Request::new(request)).await?; + self.collect_streaming_output(response.into_inner()).await + } + + /// Collect streaming output from a command response. + async fn collect_streaming_output( + &self, + mut stream: tonic::Streaming, + ) -> buck2_error::Result { + use buck2_cli_proto::command_progress; + use buck2_cli_proto::command_result; + use buck2_cli_proto::partial_result; + + let mut output = String::new(); + + while let Some(progress) = stream.next().await { + let progress = progress?; + for msg in progress.messages { + if let Some(progress) = msg.progress { + match progress { + command_progress::Progress::PartialResult(partial) => { + if let Some(partial_result::PartialResult::StdoutBytes(bytes)) = + partial.partial_result + { + output.push_str(&String::from_utf8_lossy(&bytes.data)); + } + } + command_progress::Progress::Result(result) => { + if let Some(command_result::Result::Error(err)) = result.result { + return Err(McpError::QueryFailed(err.message).into()); + } + } + _ => {} + } + } + } + } + + Ok(output) + } + + /// Collect build output from a build command response. + async fn collect_build_output( + &self, + mut stream: tonic::Streaming, + ) -> buck2_error::Result { + use buck2_cli_proto::command_progress; + use buck2_cli_proto::command_result; + + let mut result_json = String::new(); + + while let Some(progress) = stream.next().await { + let progress = progress?; + for msg in progress.messages { + if let Some(command_progress::Progress::Result(result)) = msg.progress { + match result.result { + Some(command_result::Result::BuildResponse(build_resp)) => { + let targets: Vec<_> = build_resp + .build_targets + .iter() + .map(|t| { + serde_json::json!({ + "target": t.target, + "configuration": t.configuration, + "outputs": t.outputs.iter().map(|o| &o.path).collect::>(), + }) + }) + .collect(); + + result_json = serde_json::to_string_pretty(&serde_json::json!({ + "success": build_resp.errors.is_empty(), + "project_root": build_resp.project_root, + "targets": targets, + "errors": build_resp.errors.iter() + .map(|e| &e.message) + .collect::>(), + }))?; + } + Some(command_result::Result::Error(err)) => { + return Err(McpError::BuildFailed(err.message).into()); + } + _ => {} + } + } + } + } + + Ok(result_json) + } +} diff --git a/app/buck2_mcp/src/lib.rs b/app/buck2_mcp/src/lib.rs new file mode 100644 index 000000000000..b6fc63b01100 --- /dev/null +++ b/app/buck2_mcp/src/lib.rs @@ -0,0 +1,20 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Buck2 MCP (Model Context Protocol) Server +//! +//! This crate implements an MCP server that allows AI agents to interact with +//! Buck2's build system. It provides tools for querying targets, building, and +//! managing long-running operations through a polling interface. + +pub mod daemon; +pub mod mcp; +pub mod operations; +pub mod tools; diff --git a/app/buck2_mcp/src/mcp.rs b/app/buck2_mcp/src/mcp.rs new file mode 100644 index 000000000000..06cbdba099a6 --- /dev/null +++ b/app/buck2_mcp/src/mcp.rs @@ -0,0 +1,16 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! MCP (Model Context Protocol) implementation. + +pub mod error; +pub mod protocol; +pub mod server; +pub mod transport; diff --git a/app/buck2_mcp/src/mcp/error.rs b/app/buck2_mcp/src/mcp/error.rs new file mode 100644 index 000000000000..d0a24e14a1a8 --- /dev/null +++ b/app/buck2_mcp/src/mcp/error.rs @@ -0,0 +1,84 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! MCP error types and error code constants. + +use crate::mcp::protocol::JsonRpcError; + +// Standard JSON-RPC error codes +pub const PARSE_ERROR: i32 = -32700; +pub const INVALID_REQUEST: i32 = -32600; +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const INVALID_PARAMS: i32 = -32602; +pub const INTERNAL_ERROR: i32 = -32603; + +// MCP-specific error codes (-32000 to -32099) +pub const DAEMON_CONNECTION_ERROR: i32 = -32001; +pub const BUILD_FAILED: i32 = -32002; +pub const QUERY_FAILED: i32 = -32003; +pub const INVALID_TARGET: i32 = -32004; +pub const OPERATION_NOT_FOUND: i32 = -32005; +pub const OPERATION_CANCELLED: i32 = -32006; + +/// MCP-specific errors. +#[derive(Debug, buck2_error::Error)] +#[buck2(tag = Input)] +pub enum McpError { + #[error("Failed to connect to Buck2 daemon: {0}")] + DaemonConnection(String), + + #[error("Build failed: {0}")] + BuildFailed(String), + + #[error("Query failed: {0}")] + QueryFailed(String), + + #[error("Invalid target pattern: {0}")] + InvalidTarget(String), + + #[error("Operation not found: {0}")] + OperationNotFound(String), + + #[error("Operation cancelled: {0}")] + OperationCancelled(String), + + #[error("Protocol error: {0}")] + Protocol(String), +} + +impl From for JsonRpcError { + fn from(err: McpError) -> Self { + let (code, message) = match &err { + McpError::DaemonConnection(msg) => (DAEMON_CONNECTION_ERROR, msg.clone()), + McpError::BuildFailed(msg) => (BUILD_FAILED, msg.clone()), + McpError::QueryFailed(msg) => (QUERY_FAILED, msg.clone()), + McpError::InvalidTarget(msg) => (INVALID_PARAMS, msg.clone()), + McpError::OperationNotFound(msg) => (OPERATION_NOT_FOUND, msg.clone()), + McpError::OperationCancelled(msg) => (OPERATION_CANCELLED, msg.clone()), + McpError::Protocol(msg) => (PARSE_ERROR, msg.clone()), + }; + + JsonRpcError { + code, + message, + data: None, + } + } +} + +impl From for JsonRpcError { + fn from(err: buck2_error::Error) -> Self { + JsonRpcError { + code: INTERNAL_ERROR, + message: err.to_string(), + data: None, + } + } +} diff --git a/app/buck2_mcp/src/mcp/protocol.rs b/app/buck2_mcp/src/mcp/protocol.rs new file mode 100644 index 000000000000..a385898baa9e --- /dev/null +++ b/app/buck2_mcp/src/mcp/protocol.rs @@ -0,0 +1,461 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! MCP (Model Context Protocol) JSON-RPC protocol types. +//! +//! This module defines the core protocol types for MCP communication, +//! including JSON-RPC request/response types and MCP-specific messages. + +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +/// JSON-RPC 2.0 request message. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: String, + pub id: JsonRpcId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// JSON-RPC ID - can be string, number, or null. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(untagged)] +pub enum JsonRpcId { + String(String), + Number(i64), + #[default] + Null, +} + +/// JSON-RPC 2.0 response message. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcResponse { + pub jsonrpc: String, + pub id: JsonRpcId, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl JsonRpcResponse { + pub fn success(id: JsonRpcId, result: Value) -> Self { + Self { + jsonrpc: "2.0".to_owned(), + id, + result: Some(result), + error: None, + } + } + + pub fn error(id: JsonRpcId, error: JsonRpcError) -> Self { + Self { + jsonrpc: "2.0".to_owned(), + id, + result: None, + error: Some(error), + } + } +} + +/// JSON-RPC 2.0 error object. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl JsonRpcError { + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } + + pub fn with_data(mut self, data: Value) -> Self { + self.data = Some(data); + self + } + + /// Parse error (-32700) + pub fn parse_error(message: impl Into) -> Self { + Self::new(-32700, message) + } + + /// Invalid request (-32600) + pub fn invalid_request(message: impl Into) -> Self { + Self::new(-32600, message) + } + + /// Method not found (-32601) + pub fn method_not_found(method: &str) -> Self { + Self::new(-32601, format!("Method not found: {}", method)) + } + + /// Invalid params (-32602) + pub fn invalid_params(message: impl Into) -> Self { + Self::new(-32602, message) + } + + /// Internal error (-32603) + pub fn internal_error(message: impl Into) -> Self { + Self::new(-32603, message) + } +} + +// ============================================================================ +// MCP Initialize +// ============================================================================ + +/// MCP initialize request parameters. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeParams { + pub protocol_version: String, + pub capabilities: ClientCapabilities, + pub client_info: ClientInfo, +} + +/// Client information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientInfo { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// Client capabilities. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ClientCapabilities { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub roots: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sampling: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RootsCapability { + #[serde(default)] + pub list_changed: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SamplingCapability {} + +/// MCP initialize response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResult { + pub protocol_version: String, + pub capabilities: ServerCapabilities, + pub server_info: ServerInfo, +} + +/// Server information. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerInfo { + pub name: String, +} + +/// Server capabilities. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerCapabilities { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tools: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub resources: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompts: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolsCapability { + #[serde(default)] + pub list_changed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourcesCapability { + #[serde(default)] + pub subscribe: bool, + #[serde(default)] + pub list_changed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PromptsCapability { + #[serde(default)] + pub list_changed: bool, +} + +// ============================================================================ +// MCP Tools +// ============================================================================ + +/// MCP tool definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Tool { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub input_schema: Value, +} + +/// Response to tools/list request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolsListResult { + pub tools: Vec, +} + +/// Parameters for tools/call request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCallParams { + pub name: String, + #[serde(default)] + pub arguments: Value, +} + +/// Result of tools/call request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallResult { + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +impl ToolCallResult { + pub fn text(text: impl Into) -> Self { + Self { + content: vec![ToolContent::Text { text: text.into() }], + is_error: None, + } + } + + pub fn error(message: impl Into) -> Self { + Self { + content: vec![ToolContent::Text { + text: message.into(), + }], + is_error: Some(true), + } + } + + pub fn json(value: &T) -> buck2_error::Result { + let text = serde_json::to_string_pretty(value)?; + Ok(Self::text(text)) + } +} + +/// Tool content types. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum ToolContent { + #[serde(rename = "text")] + Text { text: String }, + #[serde(rename = "image")] + Image { + data: String, + #[serde(rename = "mimeType")] + mime_type: String, + }, + #[serde(rename = "resource")] + Resource { resource: ResourceContent }, +} + +/// Embedded resource content. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResourceContent { + pub uri: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, +} + +// ============================================================================ +// MCP Notifications +// ============================================================================ + +/// JSON-RPC notification (no id field). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonRpcNotification { + pub jsonrpc: String, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl JsonRpcNotification { + pub fn new(method: impl Into) -> Self { + Self { + jsonrpc: "2.0".to_owned(), + method: method.into(), + params: None, + } + } + + pub fn with_params(mut self, params: Value) -> Self { + self.params = Some(params); + self + } +} + +// ============================================================================ +// MCP Protocol Version +// ============================================================================ + +/// Current MCP protocol version. +pub const PROTOCOL_VERSION: &str = "2024-11-05"; + +/// Server name. +pub const SERVER_NAME: &str = "buck2-mcp"; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_json_rpc_id_serialization() { + // Test string ID + let id = JsonRpcId::String("test-id".to_owned()); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, r#""test-id""#); + + // Test number ID + let id = JsonRpcId::Number(42); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "42"); + + // Test null ID + let id = JsonRpcId::Null; + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "null"); + } + + #[test] + fn test_json_rpc_id_deserialization() { + // Test string ID + let id: JsonRpcId = serde_json::from_str(r#""test-id""#).unwrap(); + assert_eq!(id, JsonRpcId::String("test-id".to_owned())); + + // Test number ID + let id: JsonRpcId = serde_json::from_str("42").unwrap(); + assert_eq!(id, JsonRpcId::Number(42)); + + // Test null ID + let id: JsonRpcId = serde_json::from_str("null").unwrap(); + assert_eq!(id, JsonRpcId::Null); + } + + #[test] + fn test_json_rpc_request_parsing() { + let json = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#; + let request: JsonRpcRequest = serde_json::from_str(json).unwrap(); + assert_eq!(request.jsonrpc, "2.0"); + assert_eq!(request.id, JsonRpcId::Number(1)); + assert_eq!(request.method, "initialize"); + assert!(request.params.is_some()); + } + + #[test] + fn test_json_rpc_response_success() { + let response = + JsonRpcResponse::success(JsonRpcId::Number(1), serde_json::json!({"key": "value"})); + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.id, JsonRpcId::Number(1)); + assert!(response.result.is_some()); + assert!(response.error.is_none()); + } + + #[test] + fn test_json_rpc_response_error() { + let response = JsonRpcResponse::error( + JsonRpcId::Number(1), + JsonRpcError::method_not_found("unknown"), + ); + assert_eq!(response.jsonrpc, "2.0"); + assert_eq!(response.id, JsonRpcId::Number(1)); + assert!(response.result.is_none()); + assert!(response.error.is_some()); + assert_eq!(response.error.unwrap().code, -32601); + } + + #[test] + fn test_tool_call_result_text() { + let result = ToolCallResult::text("Hello, world!"); + assert_eq!(result.content.len(), 1); + assert!(result.is_error.is_none()); + match &result.content[0] { + ToolContent::Text { text } => assert_eq!(text, "Hello, world!"), + _ => panic!("Expected Text content"), + } + } + + #[test] + fn test_tool_call_result_error() { + let result = ToolCallResult::error("Something went wrong"); + assert_eq!(result.content.len(), 1); + assert_eq!(result.is_error, Some(true)); + } + + #[test] + fn test_initialize_result_serialization() { + let result = InitializeResult { + protocol_version: PROTOCOL_VERSION.to_owned(), + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: false, + }), + resources: None, + prompts: None, + }, + server_info: ServerInfo { + name: SERVER_NAME.to_owned(), + }, + }; + + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains("protocolVersion")); + assert!(json.contains("serverInfo")); + assert!(json.contains("capabilities")); + } + + #[test] + fn test_tool_definition_serialization() { + let tool = Tool { + name: "query".to_owned(), + description: Some("Run a query".to_owned()), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "query": {"type": "string"} + } + }), + }; + + let json = serde_json::to_string(&tool).unwrap(); + assert!(json.contains("\"name\":\"query\"")); + assert!(json.contains("inputSchema")); + } +} diff --git a/app/buck2_mcp/src/mcp/server.rs b/app/buck2_mcp/src/mcp/server.rs new file mode 100644 index 000000000000..3d339e504a5c --- /dev/null +++ b/app/buck2_mcp/src/mcp/server.rs @@ -0,0 +1,212 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! MCP server implementation. +//! +//! Handles the main message loop and dispatches requests to tools. + +use std::sync::Arc; + +use serde_json::Value; +use tracing::debug; +use tracing::error; +use tracing::info; + +use crate::daemon::client::McpDaemonClient; +use crate::mcp::protocol::InitializeParams; +use crate::mcp::protocol::InitializeResult; +use crate::mcp::protocol::JsonRpcError; +use crate::mcp::protocol::JsonRpcId; +use crate::mcp::protocol::JsonRpcRequest; +use crate::mcp::protocol::JsonRpcResponse; +use crate::mcp::protocol::PROTOCOL_VERSION; +use crate::mcp::protocol::SERVER_NAME; +use crate::mcp::protocol::ServerCapabilities; +use crate::mcp::protocol::ServerInfo; +use crate::mcp::protocol::ToolCallParams; +use crate::mcp::protocol::ToolsCapability; +use crate::mcp::protocol::ToolsListResult; +use crate::mcp::transport::StdioTransport; +use crate::operations::manager::OperationManager; +use crate::tools::registry::ToolRegistry; + +/// MCP server state. +pub struct McpServer { + transport: StdioTransport, + tools: ToolRegistry, + daemon_client: Option, + initialized: bool, + working_dir: String, +} + +impl McpServer { + /// Create a new MCP server. + pub fn new(working_dir: String) -> Self { + let operation_manager = Arc::new(OperationManager::new()); + Self { + transport: StdioTransport::new(), + tools: ToolRegistry::new(operation_manager), + daemon_client: None, + initialized: false, + working_dir, + } + } + + /// Run the MCP server main loop. + pub async fn run(&mut self) -> buck2_error::Result<()> { + info!("Buck2 MCP server starting"); + + loop { + let message = match self.transport.read_message().await? { + Some(msg) if msg.is_empty() => continue, // Skip empty lines + Some(msg) => msg, + None => { + info!("EOF received, shutting down"); + break; + } + }; + + debug!("Received message: {}", message); + + let request: JsonRpcRequest = match serde_json::from_str(&message) { + Ok(req) => req, + Err(e) => { + let response = JsonRpcResponse::error( + JsonRpcId::Null, + JsonRpcError::parse_error(format!("Failed to parse request: {}", e)), + ); + self.send_response(&response).await?; + continue; + } + }; + + let response = self.handle_request(request).await; + self.send_response(&response).await?; + } + + Ok(()) + } + + async fn send_response(&mut self, response: &JsonRpcResponse) -> buck2_error::Result<()> { + let json = serde_json::to_string(response)?; + debug!("Sending response: {}", json); + self.transport.write_message(&json).await + } + + async fn handle_request(&mut self, request: JsonRpcRequest) -> JsonRpcResponse { + let id = request.id.clone(); + + match self.dispatch_request(request).await { + Ok(result) => JsonRpcResponse::success(id, result), + Err(error) => JsonRpcResponse::error(id, error), + } + } + + async fn dispatch_request(&mut self, request: JsonRpcRequest) -> Result { + match request.method.as_str() { + "initialize" => self.handle_initialize(request.params).await, + "initialized" => Ok(Value::Null), + "tools/list" => self.handle_tools_list().await, + "tools/call" => self.handle_tools_call(request.params).await, + "ping" => Ok(Value::Object(serde_json::Map::new())), + method => Err(JsonRpcError::method_not_found(method)), + } + } + + async fn handle_initialize(&mut self, params: Option) -> Result { + let _params: InitializeParams = match params { + Some(p) => serde_json::from_value(p).map_err(|e| { + JsonRpcError::invalid_params(format!("Invalid initialize params: {}", e)) + })?, + None => { + return Err(JsonRpcError::invalid_params("Missing initialize params")); + } + }; + + info!("Initializing MCP server"); + + match McpDaemonClient::connect(&self.working_dir).await { + Ok(client) => { + self.daemon_client = Some(client); + info!("Connected to Buck2 daemon"); + } + Err(e) => { + error!("Failed to connect to Buck2 daemon: {}", e); + // We don't fail initialization, but tools will fail if daemon is needed + } + } + + self.initialized = true; + + let result = InitializeResult { + protocol_version: PROTOCOL_VERSION.to_owned(), + capabilities: ServerCapabilities { + tools: Some(ToolsCapability { + list_changed: false, + }), + resources: None, + prompts: None, + }, + server_info: ServerInfo { + name: SERVER_NAME.to_owned(), + }, + }; + + serde_json::to_value(result) + .map_err(|e| JsonRpcError::internal_error(format!("Serialization error: {}", e))) + } + + async fn handle_tools_list(&self) -> Result { + let result = ToolsListResult { + tools: self.tools.list_tools(), + }; + + serde_json::to_value(result) + .map_err(|e| JsonRpcError::internal_error(format!("Serialization error: {}", e))) + } + + async fn handle_tools_call(&mut self, params: Option) -> Result { + let params: ToolCallParams = match params { + Some(p) => serde_json::from_value(p).map_err(|e| { + JsonRpcError::invalid_params(format!("Invalid tool call params: {}", e)) + })?, + None => { + return Err(JsonRpcError::invalid_params("Missing tool call params")); + } + }; + + info!("Calling tool: {}", params.name); + + let client = match &mut self.daemon_client { + Some(c) => c, + None => match McpDaemonClient::connect(&self.working_dir).await { + Ok(client) => { + self.daemon_client = Some(client); + self.daemon_client.as_mut().unwrap() + } + Err(e) => { + return Err(JsonRpcError::new( + crate::mcp::error::DAEMON_CONNECTION_ERROR, + format!("Failed to connect to Buck2 daemon: {}", e), + )); + } + }, + }; + + let result = self + .tools + .call_tool(¶ms.name, params.arguments, client) + .await + .map_err(|e| JsonRpcError::internal_error(e.to_string()))?; + + serde_json::to_value(result) + .map_err(|e| JsonRpcError::internal_error(format!("Serialization error: {}", e))) + } +} diff --git a/app/buck2_mcp/src/mcp/transport.rs b/app/buck2_mcp/src/mcp/transport.rs new file mode 100644 index 000000000000..c33a1b6756c6 --- /dev/null +++ b/app/buck2_mcp/src/mcp/transport.rs @@ -0,0 +1,66 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Stdio transport for MCP protocol. +//! +//! MCP uses newline-delimited JSON messages over stdin/stdout. + +use tokio::io::AsyncBufReadExt; +use tokio::io::AsyncWriteExt; +use tokio::io::BufReader; + +/// Stdio transport for MCP communication. +pub struct StdioTransport { + stdin: BufReader, + stdout: tokio::io::Stdout, +} + +impl StdioTransport { + /// Create a new stdio transport. + pub fn new() -> Self { + Self { + stdin: BufReader::new(tokio::io::stdin()), + stdout: tokio::io::stdout(), + } + } + + /// Read a single JSON-RPC message from stdin. + /// + /// Returns `None` on EOF. + pub async fn read_message(&mut self) -> buck2_error::Result> { + let mut line = String::new(); + match self.stdin.read_line(&mut line).await { + Ok(0) => Ok(None), // EOF + Ok(_) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + Ok(Some(String::new())) + } else { + Ok(Some(trimmed.to_owned())) + } + } + Err(e) => Err(e.into()), + } + } + + /// Write a JSON-RPC message to stdout. + pub async fn write_message(&mut self, message: &str) -> buck2_error::Result<()> { + self.stdout.write_all(message.as_bytes()).await?; + self.stdout.write_all(b"\n").await?; + self.stdout.flush().await?; + Ok(()) + } +} + +impl Default for StdioTransport { + fn default() -> Self { + Self::new() + } +} diff --git a/app/buck2_mcp/src/operations.rs b/app/buck2_mcp/src/operations.rs new file mode 100644 index 000000000000..1b7cf94c1ae4 --- /dev/null +++ b/app/buck2_mcp/src/operations.rs @@ -0,0 +1,14 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Async operation management for long-running tasks. + +pub mod manager; +pub mod operation; diff --git a/app/buck2_mcp/src/operations/manager.rs b/app/buck2_mcp/src/operations/manager.rs new file mode 100644 index 000000000000..1001ace08859 --- /dev/null +++ b/app/buck2_mcp/src/operations/manager.rs @@ -0,0 +1,266 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Operation manager for tracking async operations. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::RwLock; +use uuid::Uuid; + +use crate::mcp::error::McpError; +use crate::operations::operation::Operation; +use crate::operations::operation::OperationId; +use crate::operations::operation::OperationProgress; +use crate::operations::operation::OperationResult; +use crate::operations::operation::OperationStatus; + +/// Manager for async operations. +pub struct OperationManager { + operations: RwLock>, +} + +impl OperationManager { + /// Create a new operation manager. + pub fn new() -> Self { + Self { + operations: RwLock::new(HashMap::new()), + } + } + + /// Create a new operation and return its ID. + pub async fn create_operation( + &self, + operation_type: &str, + ) -> (OperationId, tokio::sync::oneshot::Receiver<()>) { + let id = Uuid::new_v4().to_string(); + let (operation, cancel_receiver) = Operation::new(id.clone(), operation_type.to_owned()); + + let mut operations = self.operations.write().await; + operations.insert(id.clone(), operation); + + (id, cancel_receiver) + } + + /// Update the progress of an operation. + pub async fn update_progress(&self, id: &OperationId, progress: OperationProgress) { + let mut operations = self.operations.write().await; + if let Some(op) = operations.get_mut(id) { + op.update_progress(progress); + } + } + + /// Complete an operation with a result. + pub async fn complete_operation(&self, id: &OperationId, result: OperationResult) { + let mut operations = self.operations.write().await; + if let Some(op) = operations.get_mut(id) { + op.complete(result); + } + } + + /// Fail an operation with an error. + pub async fn fail_operation(&self, id: &OperationId, error: String) { + let mut operations = self.operations.write().await; + if let Some(op) = operations.get_mut(id) { + op.fail(error); + } + } + + /// Cancel an operation. + pub async fn cancel_operation(&self, id: &OperationId) -> buck2_error::Result<()> { + let mut operations = self.operations.write().await; + if let Some(op) = operations.get_mut(id) { + op.cancel(); + Ok(()) + } else { + Err(McpError::OperationNotFound(id.clone()).into()) + } + } + + /// Get the status of an operation. + pub async fn get_status(&self, id: &OperationId) -> buck2_error::Result { + let operations = self.operations.read().await; + if let Some(op) = operations.get(id) { + Ok(op.status.clone()) + } else { + Err(McpError::OperationNotFound(id.clone()).into()) + } + } + + /// Wait for an operation to complete with optional timeout. + pub async fn wait_for_result( + self: &Arc, + id: &OperationId, + timeout: Option, + ) -> buck2_error::Result { + let deadline = timeout.map(|t| std::time::Instant::now() + t); + + loop { + let status = self.get_status(id).await?; + match &status { + OperationStatus::Running { .. } => { + if let Some(deadline) = deadline + && std::time::Instant::now() >= deadline + { + return Ok(status); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + _ => return Ok(status), + } + } + } + + /// List all operations. + pub async fn list_operations(&self, include_completed: bool) -> Vec { + let operations = self.operations.read().await; + operations + .values() + .filter(|op| include_completed || matches!(op.status, OperationStatus::Running { .. })) + .map(|op| OperationInfo { + id: op.id.clone(), + operation_type: op.operation_type.clone(), + status: op.status.clone(), + }) + .collect() + } + + /// Clean up old completed operations (call periodically). + pub async fn cleanup_old_operations(&self, max_age: Duration) { + let mut operations = self.operations.write().await; + let now = chrono::Utc::now(); + + operations.retain(|_, op| match &op.status { + OperationStatus::Running { .. } => true, + _ => { + let age = now.signed_duration_since(op.started_at); + age.to_std().unwrap_or(Duration::ZERO) < max_age + } + }); + } +} + +impl Default for OperationManager { + fn default() -> Self { + Self::new() + } +} + +/// Summary info about an operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct OperationInfo { + pub id: OperationId, + pub operation_type: String, + pub status: OperationStatus, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_create_operation() { + let manager = OperationManager::new(); + let (id, _cancel_receiver) = manager.create_operation("build").await; + + assert!(!id.is_empty()); + + let status = manager.get_status(&id).await.unwrap(); + assert!(matches!(status, OperationStatus::Running { .. })); + } + + #[tokio::test] + async fn test_complete_operation() { + let manager = OperationManager::new(); + let (id, _cancel_receiver) = manager.create_operation("build").await; + + manager + .complete_operation( + &id, + OperationResult { + success: true, + output: "Build complete".to_owned(), + }, + ) + .await; + + let status = manager.get_status(&id).await.unwrap(); + match status { + OperationStatus::Completed { result } => { + assert!(result.success); + assert_eq!(result.output, "Build complete"); + } + _ => panic!("Expected completed status"), + } + } + + #[tokio::test] + async fn test_fail_operation() { + let manager = OperationManager::new(); + let (id, _cancel_receiver) = manager.create_operation("build").await; + + manager.fail_operation(&id, "Build failed".to_owned()).await; + + let status = manager.get_status(&id).await.unwrap(); + match status { + OperationStatus::Failed { error } => { + assert_eq!(error, "Build failed"); + } + _ => panic!("Expected failed status"), + } + } + + #[tokio::test] + async fn test_cancel_operation() { + let manager = OperationManager::new(); + let (id, _cancel_receiver) = manager.create_operation("build").await; + + manager.cancel_operation(&id).await.unwrap(); + + let status = manager.get_status(&id).await.unwrap(); + assert!(matches!(status, OperationStatus::Cancelled)); + } + + #[tokio::test] + async fn test_list_operations() { + let manager = OperationManager::new(); + + let (id1, _) = manager.create_operation("build").await; + let (id2, _) = manager.create_operation("query").await; + + manager + .complete_operation( + &id1, + OperationResult { + success: true, + output: "Done".to_owned(), + }, + ) + .await; + + // List only running operations + let running = manager.list_operations(false).await; + assert_eq!(running.len(), 1); + assert_eq!(running[0].id, id2); + + // List all operations + let all = manager.list_operations(true).await; + assert_eq!(all.len(), 2); + } + + #[tokio::test] + async fn test_get_nonexistent_operation() { + let manager = OperationManager::new(); + let result = manager.get_status(&"nonexistent".to_owned()).await; + assert!(result.is_err()); + } +} diff --git a/app/buck2_mcp/src/operations/operation.rs b/app/buck2_mcp/src/operations/operation.rs new file mode 100644 index 000000000000..ac315ae9f091 --- /dev/null +++ b/app/buck2_mcp/src/operations/operation.rs @@ -0,0 +1,110 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Operation state types. + +use chrono::DateTime; +use chrono::Utc; +use serde::Serialize; +use tokio::sync::oneshot; + +/// Unique identifier for an operation. +pub type OperationId = String; + +/// Operation status. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum OperationStatus { + Running { progress: OperationProgress }, + Completed { result: OperationResult }, + Failed { error: String }, + Cancelled, +} + +/// Progress information for a running operation. +#[derive(Debug, Clone, Default, Serialize)] +pub struct OperationProgress { + pub started_at: String, + pub elapsed_secs: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub actions_completed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub actions_total: Option, +} + +impl OperationProgress { + pub fn new(started_at: DateTime) -> Self { + let elapsed = Utc::now().signed_duration_since(started_at); + Self { + started_at: started_at.to_rfc3339(), + elapsed_secs: elapsed.num_milliseconds() as f64 / 1000.0, + message: None, + actions_completed: None, + actions_total: None, + } + } +} + +/// Result of a completed operation. +#[derive(Debug, Clone, Serialize)] +pub struct OperationResult { + pub success: bool, + pub output: String, +} + +/// An async operation. +pub struct Operation { + pub id: OperationId, + pub operation_type: String, + pub started_at: DateTime, + pub status: OperationStatus, + pub cancel_sender: Option>, +} + +impl Operation { + pub fn new(id: OperationId, operation_type: String) -> (Self, oneshot::Receiver<()>) { + let (cancel_sender, cancel_receiver) = oneshot::channel(); + let operation = Self { + id, + operation_type, + started_at: Utc::now(), + status: OperationStatus::Running { + progress: OperationProgress::new(Utc::now()), + }, + cancel_sender: Some(cancel_sender), + }; + (operation, cancel_receiver) + } + + pub fn update_progress(&mut self, progress: OperationProgress) { + if matches!(self.status, OperationStatus::Running { .. }) { + self.status = OperationStatus::Running { progress }; + } + } + + pub fn complete(&mut self, result: OperationResult) { + self.status = OperationStatus::Completed { result }; + self.cancel_sender = None; + } + + pub fn fail(&mut self, error: String) { + self.status = OperationStatus::Failed { error }; + self.cancel_sender = None; + } + + pub fn cancel(&mut self) { + if let Some(sender) = self.cancel_sender.take() { + let _ = sender.send(()); + } + self.status = OperationStatus::Cancelled; + } +} diff --git a/app/buck2_mcp/src/tools.rs b/app/buck2_mcp/src/tools.rs new file mode 100644 index 000000000000..97f9ecb42178 --- /dev/null +++ b/app/buck2_mcp/src/tools.rs @@ -0,0 +1,21 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! MCP tools implementation. + +pub mod build; +pub mod build_async; +pub mod operations; +pub mod query; +pub mod registry; +pub mod targets; + +pub use registry::McpTool; +pub use registry::ToolRegistry; diff --git a/app/buck2_mcp/src/tools/build.rs b/app/buck2_mcp/src/tools/build.rs new file mode 100644 index 000000000000..77eea16bc457 --- /dev/null +++ b/app/buck2_mcp/src/tools/build.rs @@ -0,0 +1,86 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Build tool for MCP server. + +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; + +use crate::daemon::client::McpDaemonClient; +use crate::mcp::protocol::Tool; +use crate::mcp::protocol::ToolCallResult; +use crate::tools::registry::McpTool; + +/// Build tool - builds specified Buck2 targets. +pub struct BuildTool; + +#[derive(Debug, Deserialize)] +struct BuildArgs { + /// Target patterns to build + targets: Vec, + /// Target platform for configuration + target_platform: Option, + /// Whether to show output paths + #[serde(default)] + show_output: bool, +} + +#[async_trait::async_trait] +impl McpTool for BuildTool { + fn definition(&self) -> Tool { + Tool { + name: "build".to_owned(), + description: Some( + "Build the specified Buck2 targets and return build results. \ + This is a synchronous operation that blocks until the build completes." + .to_owned(), + ), + input_schema: json!({ + "type": "object", + "required": ["targets"], + "properties": { + "targets": { + "type": "array", + "items": { "type": "string" }, + "description": "Target patterns to build (e.g., ['//foo:bar', '//baz/...'])" + }, + "target_platform": { + "type": "string", + "description": "Target platform for configuration" + }, + "show_output": { + "type": "boolean", + "default": false, + "description": "Include output file paths in result" + } + } + }), + } + } + + async fn call( + &self, + arguments: Value, + client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let args: BuildArgs = serde_json::from_value(arguments)?; + + let result = client + .build( + &args.targets, + args.target_platform.as_deref(), + args.show_output, + ) + .await?; + + Ok(ToolCallResult::text(result)) + } +} diff --git a/app/buck2_mcp/src/tools/build_async.rs b/app/buck2_mcp/src/tools/build_async.rs new file mode 100644 index 000000000000..a9f7ea85d516 --- /dev/null +++ b/app/buck2_mcp/src/tools/build_async.rs @@ -0,0 +1,136 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Async build tool for MCP server. + +use std::sync::Arc; + +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; + +use crate::daemon::client::McpDaemonClient; +use crate::mcp::protocol::Tool; +use crate::mcp::protocol::ToolCallResult; +use crate::operations::manager::OperationManager; +use crate::operations::operation::OperationResult; +use crate::tools::registry::McpTool; + +/// Async build tool - starts a build and returns an operation ID for polling. +pub struct BuildAsyncTool { + operation_manager: Arc, +} + +impl BuildAsyncTool { + pub fn new(operation_manager: Arc) -> Self { + Self { operation_manager } + } +} + +#[derive(Debug, Clone, Deserialize)] +struct BuildAsyncArgs { + /// Target patterns to build + targets: Vec, + /// Target platform for configuration + target_platform: Option, + /// Whether to show output paths + #[serde(default = "default_show_output")] + show_output: bool, +} + +fn default_show_output() -> bool { + true +} + +#[async_trait::async_trait] +impl McpTool for BuildAsyncTool { + fn definition(&self) -> Tool { + Tool { + name: "build_async".to_owned(), + description: Some( + "Start an async build operation. Returns an operation ID that can be used \ + to poll for status and retrieve the result when complete." + .to_owned(), + ), + input_schema: json!({ + "type": "object", + "required": ["targets"], + "properties": { + "targets": { + "type": "array", + "items": { "type": "string" }, + "description": "Target patterns to build (e.g., ['//foo:bar', '//baz/...'])" + }, + "target_platform": { + "type": "string", + "description": "Target platform for configuration" + }, + "show_output": { + "type": "boolean", + "default": true, + "description": "Include output file paths in result" + } + } + }), + } + } + + async fn call( + &self, + arguments: Value, + client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let args: BuildAsyncArgs = serde_json::from_value(arguments)?; + + // Create the operation + let (operation_id, _cancel_receiver) = + self.operation_manager.create_operation("build").await; + + // Clone the client and operation manager for the spawned task + let mut client = client.clone(); + let operation_manager = Arc::clone(&self.operation_manager); + let op_id = operation_id.clone(); + + // Spawn the build as a separate tokio task + tokio::spawn(async move { + let result = client + .build( + &args.targets, + args.target_platform.as_deref(), + args.show_output, + ) + .await; + + match result { + Ok(output) => { + operation_manager + .complete_operation( + &op_id, + OperationResult { + success: true, + output, + }, + ) + .await; + } + Err(e) => { + operation_manager + .fail_operation(&op_id, e.to_string()) + .await; + } + } + }); + + Ok(ToolCallResult::json(&json!({ + "operation_id": operation_id, + "message": "Build operation started. Use operation_status to check progress." + }))?) + } +} diff --git a/app/buck2_mcp/src/tools/operations.rs b/app/buck2_mcp/src/tools/operations.rs new file mode 100644 index 000000000000..64b7cd8f5f8e --- /dev/null +++ b/app/buck2_mcp/src/tools/operations.rs @@ -0,0 +1,253 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Operation management tools for MCP server. + +use std::sync::Arc; +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; + +use crate::daemon::client::McpDaemonClient; +use crate::mcp::protocol::Tool; +use crate::mcp::protocol::ToolCallResult; +use crate::operations::manager::OperationManager; +use crate::tools::registry::McpTool; + +// ============================================================================ +// OperationStatusTool +// ============================================================================ + +/// Tool to get the status of an async operation. +pub struct OperationStatusTool { + operation_manager: Arc, +} + +impl OperationStatusTool { + pub fn new(operation_manager: Arc) -> Self { + Self { operation_manager } + } +} + +#[derive(Debug, Deserialize)] +struct OperationStatusArgs { + operation_id: String, +} + +#[async_trait::async_trait] +impl McpTool for OperationStatusTool { + fn definition(&self) -> Tool { + Tool { + name: "operation_status".to_owned(), + description: Some( + "Get the current status and progress of an async operation.".to_owned(), + ), + input_schema: json!({ + "type": "object", + "required": ["operation_id"], + "properties": { + "operation_id": { + "type": "string", + "description": "Operation ID from build_async" + } + } + }), + } + } + + async fn call( + &self, + arguments: Value, + _client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let args: OperationStatusArgs = serde_json::from_value(arguments)?; + let status = self + .operation_manager + .get_status(&args.operation_id) + .await?; + Ok(ToolCallResult::json(&status)?) + } +} + +// ============================================================================ +// OperationResultTool +// ============================================================================ + +/// Tool to get the result of a completed operation. +pub struct OperationResultTool { + operation_manager: Arc, +} + +impl OperationResultTool { + pub fn new(operation_manager: Arc) -> Self { + Self { operation_manager } + } +} + +#[derive(Debug, Deserialize)] +struct OperationResultArgs { + operation_id: String, + #[serde(default)] + timeout_secs: Option, +} + +#[async_trait::async_trait] +impl McpTool for OperationResultTool { + fn definition(&self) -> Tool { + Tool { + name: "operation_result".to_owned(), + description: Some( + "Get the final result of a completed operation. \ + Optionally wait for completion with a timeout." + .to_owned(), + ), + input_schema: json!({ + "type": "object", + "required": ["operation_id"], + "properties": { + "operation_id": { + "type": "string" + }, + "timeout_secs": { + "type": "number", + "description": "Max seconds to wait (0 = don't wait)" + } + } + }), + } + } + + async fn call( + &self, + arguments: Value, + _client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let args: OperationResultArgs = serde_json::from_value(arguments)?; + + let timeout = args.timeout_secs.map(Duration::from_secs_f64); + let status = self + .operation_manager + .wait_for_result(&args.operation_id, timeout) + .await?; + + Ok(ToolCallResult::json(&status)?) + } +} + +// ============================================================================ +// OperationCancelTool +// ============================================================================ + +/// Tool to cancel a running operation. +pub struct OperationCancelTool { + operation_manager: Arc, +} + +impl OperationCancelTool { + pub fn new(operation_manager: Arc) -> Self { + Self { operation_manager } + } +} + +#[derive(Debug, Deserialize)] +struct OperationCancelArgs { + operation_id: String, +} + +#[async_trait::async_trait] +impl McpTool for OperationCancelTool { + fn definition(&self) -> Tool { + Tool { + name: "operation_cancel".to_owned(), + description: Some("Cancel a running async operation.".to_owned()), + input_schema: json!({ + "type": "object", + "required": ["operation_id"], + "properties": { + "operation_id": { + "type": "string" + } + } + }), + } + } + + async fn call( + &self, + arguments: Value, + _client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let args: OperationCancelArgs = serde_json::from_value(arguments)?; + self.operation_manager + .cancel_operation(&args.operation_id) + .await?; + Ok(ToolCallResult::json(&json!({ + "cancelled": true, + "operation_id": args.operation_id + }))?) + } +} + +// ============================================================================ +// OperationListTool +// ============================================================================ + +/// Tool to list all active operations. +pub struct OperationListTool { + operation_manager: Arc, +} + +impl OperationListTool { + pub fn new(operation_manager: Arc) -> Self { + Self { operation_manager } + } +} + +#[derive(Debug, Deserialize)] +struct OperationListArgs { + #[serde(default)] + include_completed: bool, +} + +#[async_trait::async_trait] +impl McpTool for OperationListTool { + fn definition(&self) -> Tool { + Tool { + name: "operation_list".to_owned(), + description: Some("List all active and recent operations.".to_owned()), + input_schema: json!({ + "type": "object", + "properties": { + "include_completed": { + "type": "boolean", + "default": false + } + } + }), + } + } + + async fn call( + &self, + arguments: Value, + _client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let args: OperationListArgs = serde_json::from_value(arguments)?; + let operations = self + .operation_manager + .list_operations(args.include_completed) + .await; + Ok(ToolCallResult::json(&json!({ + "operations": operations + }))?) + } +} diff --git a/app/buck2_mcp/src/tools/query.rs b/app/buck2_mcp/src/tools/query.rs new file mode 100644 index 000000000000..425300ca201e --- /dev/null +++ b/app/buck2_mcp/src/tools/query.rs @@ -0,0 +1,121 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Query tool for MCP server. + +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; + +use crate::daemon::client::McpDaemonClient; +use crate::mcp::protocol::Tool; +use crate::mcp::protocol::ToolCallResult; +use crate::tools::registry::McpTool; + +/// Query tool - runs cquery or uquery on the Buck2 target graph. +pub struct QueryTool; + +#[derive(Debug, Deserialize)] +struct QueryArgs { + /// The query expression (e.g., "deps(//foo:bar)") + query: String, + /// Query type: "cquery" or "uquery" + #[serde(default = "default_query_type")] + query_type: String, + /// Target platform for cquery (optional) + target_platform: Option, + /// Target universe for cquery (optional) + target_universe: Option>, + /// Output attributes to include (optional) + output_attributes: Option>, +} + +fn default_query_type() -> String { + "cquery".to_owned() +} + +#[async_trait::async_trait] +impl McpTool for QueryTool { + fn definition(&self) -> Tool { + Tool { + name: "query".to_owned(), + description: Some( + "Run cquery or uquery on the Buck2 target graph. \ + cquery operates on the configured target graph (with resolved selects), \ + uquery operates on the unconfigured graph." + .to_owned(), + ), + input_schema: json!({ + "type": "object", + "required": ["query"], + "properties": { + "query": { + "type": "string", + "description": "The query expression (e.g., 'deps(//foo:bar)', 'rdeps(//..., //foo:bar)')" + }, + "query_type": { + "type": "string", + "enum": ["cquery", "uquery"], + "default": "cquery", + "description": "Type of query: cquery (configured) or uquery (unconfigured)" + }, + "target_platform": { + "type": "string", + "description": "Target platform for configuration (cquery only)" + }, + "target_universe": { + "type": "array", + "items": { "type": "string" }, + "description": "Target universe to limit query scope (cquery only)" + }, + "output_attributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Attributes to include in output (e.g., ['name', 'deps', 'srcs'])" + } + } + }), + } + } + + async fn call( + &self, + arguments: Value, + client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let args: QueryArgs = serde_json::from_value(arguments)?; + + let output = match args.query_type.as_str() { + "cquery" => { + client + .cquery( + &args.query, + args.target_platform.as_deref(), + args.target_universe.as_deref(), + args.output_attributes.as_deref(), + ) + .await? + } + "uquery" => { + client + .uquery(&args.query, args.output_attributes.as_deref()) + .await? + } + _ => { + return Ok(ToolCallResult::error(format!( + "Unknown query type: {}", + args.query_type + ))); + } + }; + + Ok(ToolCallResult::text(output)) + } +} diff --git a/app/buck2_mcp/src/tools/registry.rs b/app/buck2_mcp/src/tools/registry.rs new file mode 100644 index 000000000000..f14f8de66b5c --- /dev/null +++ b/app/buck2_mcp/src/tools/registry.rs @@ -0,0 +1,88 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Tool registry for MCP server. + +use std::sync::Arc; + +use serde_json::Value; + +use crate::daemon::client::McpDaemonClient; +use crate::mcp::protocol::Tool; +use crate::mcp::protocol::ToolCallResult; +use crate::operations::manager::OperationManager; +use crate::tools::build::BuildTool; +use crate::tools::build_async::BuildAsyncTool; +use crate::tools::operations::OperationCancelTool; +use crate::tools::operations::OperationListTool; +use crate::tools::operations::OperationResultTool; +use crate::tools::operations::OperationStatusTool; +use crate::tools::query::QueryTool; +use crate::tools::targets::TargetsTool; + +/// Trait for MCP tools. +#[async_trait::async_trait] +pub trait McpTool: Send + Sync { + /// Get the tool definition. + fn definition(&self) -> Tool; + + /// Call the tool with the given arguments. + async fn call( + &self, + arguments: Value, + client: &mut McpDaemonClient, + ) -> buck2_error::Result; +} + +/// Registry of available MCP tools. +pub struct ToolRegistry { + tools: Vec>, +} + +impl ToolRegistry { + /// Create a new tool registry with all available tools. + pub fn new(operation_manager: Arc) -> Self { + Self { + tools: vec![ + Box::new(QueryTool), + Box::new(BuildTool), + Box::new(TargetsTool), + Box::new(BuildAsyncTool::new(operation_manager.clone())), + Box::new(OperationStatusTool::new(operation_manager.clone())), + Box::new(OperationResultTool::new(operation_manager.clone())), + Box::new(OperationCancelTool::new(operation_manager.clone())), + Box::new(OperationListTool::new(operation_manager)), + ], + } + } + + /// List all available tools. + pub fn list_tools(&self) -> Vec { + self.tools.iter().map(|t| t.definition()).collect() + } + + /// Call a tool by name. + pub async fn call_tool( + &self, + name: &str, + arguments: Value, + client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let tool = self + .tools + .iter() + .find(|t| t.definition().name == name) + .ok_or_else(|| { + buck2_error::buck2_error!(buck2_error::ErrorTag::Input, "Unknown tool: {}", name) + })?; + + tool.call(arguments, client).await + } +} diff --git a/app/buck2_mcp/src/tools/targets.rs b/app/buck2_mcp/src/tools/targets.rs new file mode 100644 index 000000000000..bcdec5e5586f --- /dev/null +++ b/app/buck2_mcp/src/tools/targets.rs @@ -0,0 +1,90 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is dual-licensed under either the MIT license found in the + * LICENSE-MIT file in the root directory of this source tree or the Apache + * License, Version 2.0 found in the LICENSE-APACHE file in the root directory + * of this source tree. You may select, at your option, one of the + * above-listed licenses. + */ + +//! Targets tool for MCP server. + +use serde::Deserialize; +use serde_json::Value; +use serde_json::json; + +use crate::daemon::client::McpDaemonClient; +use crate::mcp::protocol::Tool; +use crate::mcp::protocol::ToolCallResult; +use crate::tools::registry::McpTool; + +/// Targets tool - lists and inspects targets matching patterns. +pub struct TargetsTool; + +#[derive(Debug, Deserialize)] +struct TargetsArgs { + /// Target patterns to query + patterns: Vec, + /// Output attributes + output_attributes: Option>, + /// Output format: "json" or "text" + #[serde(default = "default_format")] + format: String, +} + +fn default_format() -> String { + "json".to_owned() +} + +#[async_trait::async_trait] +impl McpTool for TargetsTool { + fn definition(&self) -> Tool { + Tool { + name: "targets".to_owned(), + description: Some( + "List and inspect targets matching the specified patterns.".to_owned(), + ), + input_schema: json!({ + "type": "object", + "required": ["patterns"], + "properties": { + "patterns": { + "type": "array", + "items": { "type": "string" }, + "description": "Target patterns to list (e.g., ['//foo/...', '//bar:baz'])" + }, + "output_attributes": { + "type": "array", + "items": { "type": "string" }, + "description": "Attributes to output (e.g., ['name', 'deps', 'visibility'])" + }, + "format": { + "type": "string", + "enum": ["json", "text"], + "default": "json", + "description": "Output format" + } + } + }), + } + } + + async fn call( + &self, + arguments: Value, + client: &mut McpDaemonClient, + ) -> buck2_error::Result { + let args: TargetsArgs = serde_json::from_value(arguments)?; + + let result = client + .targets( + &args.patterns, + args.output_attributes.as_deref(), + &args.format, + ) + .await?; + + Ok(ToolCallResult::text(result)) + } +}