diff --git a/Cargo.lock b/Cargo.lock index f9efa4b038..cae8ef7118 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1013,11 +1013,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core 0.5.5", + "form_urlencoded", "bytes 1.11.0", "futures-util", "http 1.3.1", "http-body 1.0.1", "http-body-util", + "hyper 1.7.0", + "hyper-util", "itoa", "matchit 0.8.4", "memchr", @@ -1025,10 +1028,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper 1.0.2", + "tokio", "tower 0.5.2", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1068,6 +1076,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1085,6 +1094,28 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "axum-server" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +dependencies = [ + "arc-swap", + "bytes 1.10.1", + "fs-err", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.7.0", + "hyper-util", + "pin-project-lite", + "rustls 0.23.35", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", +] + [[package]] name = "backoff" version = "0.4.0" @@ -2200,6 +2231,24 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "cordyceps" version = "0.3.4" @@ -3101,6 +3150,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -3709,6 +3767,16 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "fs-err" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62d91fd049c123429b018c47887d3f75a265540dd3c30ba9cb7bae9197edb03a" +dependencies = [ + "autocfg", + "tokio", +] + [[package]] name = "fs-set-times" version = "0.20.3" @@ -4066,6 +4134,30 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags 2.10.0", + "ignore", + "walkdir", +] + [[package]] name = "goldenfile" version = "1.9.1" @@ -4170,6 +4262,7 @@ dependencies = [ "futures-util", "fuzzy-matcher", "gag", + "globwalk", "golem-client", "golem-common", "golem-rib", @@ -4187,13 +4280,18 @@ dependencies = [ "jsonschema", "lenient_bool", "log", + "mime_guess", "minijinja", "moonbit-component-generator", "nanoid", "native-tls", "nondestructive", +<<<<<<< HEAD "openssl", "openssl-sys", +======= + "once_cell", +>>>>>>> BenraouaneSoufiane/mcp-server "phf 0.11.3", "pretty_assertions", "pretty_env_logger", @@ -4202,6 +4300,8 @@ dependencies = [ "quote", "regex", "reqwest 0.12.24", + "rust-mcp-sdk", + "schemars 0.8.22", "semver", "serde", "serde_derive", @@ -4418,6 +4518,23 @@ dependencies = [ "wasmtime-wasi-http", ] +[[package]] +name = "golem-mcp-client" +version = "0.1.3" +dependencies = [ + "async-trait", + "colored", + "futures", + "regex", + "rust-mcp-sdk", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "golem-openapi-client-generator" version = "0.0.17" @@ -5745,6 +5862,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd62e6b5e86ea8eeeb8db1de02880a6abc01a397b2ebb64b5d74ac255318f5cb" +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "im-rc" version = "15.1.0" @@ -6562,6 +6695,12 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -8745,6 +8884,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "psm" version = "0.1.28" @@ -8770,6 +8915,16 @@ dependencies = [ "tint", ] +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pulldown-cmark" version = "0.13.0" @@ -9218,6 +9373,8 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "async-compression", "base64 0.22.1", + "cookie", + "cookie_store", "bytes 1.11.0", "encoding_rs", "futures-channel", @@ -9512,6 +9669,76 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust-mcp-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b647a85c9da2eaf14e67d39cb067a8157a66bd2c0dc53ef1051a84f45edfae24" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.109", +] + +[[package]] +name = "rust-mcp-schema" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba217e6fcb043bba9e194209bff92c35294093187504d1443832ca2051816753" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "rust-mcp-sdk" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da1520c6524a58f572cefeb92e0d6fff68c47ac3a30c1eab8169df5d270514b" +dependencies = [ + "async-trait", + "axum 0.8.6", + "axum-server", + "base64 0.22.1", + "bytes 1.10.1", + "futures", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "rust-mcp-macros", + "rust-mcp-schema", + "rust-mcp-transport", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + +[[package]] +name = "rust-mcp-transport" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be7b63ad5155c134856e1ffdb4bc1df00324eb4f23c6f314e6a6c03606a4a4" +dependencies = [ + "async-trait", + "bytes 1.10.1", + "futures", + "reqwest 0.12.24", + "rust-mcp-schema", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tracing", +] + [[package]] name = "rustc-demangle" version = "0.1.26" @@ -10061,6 +10288,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap 2.12.0", "itoa", "memchr", "ryu", diff --git a/Cargo.toml b/Cargo.toml index 48f39c2e49..ce25afbc03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "cli/golem", "cli/golem-cli", "cli/golem-templates", + "cli/golem-cli/tests/mcp-client" ] exclude = [ diff --git a/cli/golem-cli/Cargo.toml b/cli/golem-cli/Cargo.toml index ac22b053b0..801e70113c 100644 --- a/cli/golem-cli/Cargo.toml +++ b/cli/golem-cli/Cargo.toml @@ -12,7 +12,8 @@ build = "build.rs" [features] default = [] -server-commands = [] +server-commands = ["dep:axum", "dep:schemars", "dep:globwalk", "dep:mime_guess"] +mcp-reuse-discovery = [] [lib] harness = false @@ -36,6 +37,9 @@ golem-rib = { workspace = true } golem-rib-repl = { workspace = true } golem-wasm = { workspace = true , default-features = true } golem-wasm-derive = { workspace = true } +rust-mcp-sdk = "0.7.2" +once_cell = "1.19" + # External deps anyhow = { workspace = true } @@ -86,7 +90,7 @@ reqwest = { workspace = true } semver = { workspace = true } serde = { workspace = true } serde_derive = { workspace = true } -serde_json = { workspace = true } +serde_json = { workspace = true, features = ["preserve_order"] } serde_yaml = { workspace = true } shadow-rs = { workspace = true } shlex = { workspace = true } @@ -125,13 +129,17 @@ wit-encoder = { workspace = true } wit-parser = { workspace = true } webbrowser = { workspace = true } warp = { workspace = true } +axum = { workspace = true, optional = true } +globwalk = { version = "0.9", optional = true } +schemars = { version = "0.8", features = ["derive"], optional = true } +mime_guess = { version = "2.0.5", optional = true } + [target.'cfg(not(any(target_os = "windows", target_vendor = "apple")))'.dependencies] openssl = { workspace = true } openssl-sys = { workspace = true } [dev-dependencies] -axum = { workspace = true } log = { workspace = true } pretty_assertions = { workspace = true } pretty_env_logger = { workspace = true } diff --git a/cli/golem-cli/README.md b/cli/golem-cli/README.md new file mode 100644 index 0000000000..5797b96ede --- /dev/null +++ b/cli/golem-cli/README.md @@ -0,0 +1,44 @@ +# Need MCP server? + +Golem CLI comes with http streamable mcp server, enables you to interact with all golem commands using any agent such as Claude Caude or Openai Codex. + +# What are available tools? +All relevant Golem CLI commands are available as tools, refer to this to see them in action: https://youtu.be/t5dnCSYQg_0 + +# What are available resources? +Golem MCP server will provide all existing manifests (yaml or yml) in the current working directory (where the mcp launches) as resources, you can then read such manifest from such llm agent using just single prompt like: what's inside @suchmanifest (drop down menu will appear, just when type @), refer to this video: https://youtu.be/95BXexeZjj4 + + +--- + +# Start the MCP server + +Try to build the package with features enabled +```bash +cargo build -p golem-cli --release --features "server-commands" +``` +then start the binary + +```bash +./target/release/golem-cli --serve --serve-port 1232 +``` + +# Need MCP client? +Golem also comes with basic http streamable mcp client, enables you to interact with the Golem MCP server if you want standlaone client or depends of such use case. + +# Start the MCP server + +Try to make the ports identical (edit tests/mcp-client/src/main.rs to match the server port), then build the package +```bash +cargo build -p golem-mcp-client --release +``` +then start the binary + +```bash +./target/release/golem-mcp-client +``` +There is a video for the e2e mcp server/client testing: https://youtu.be/ONUJ6BOyHDI + + + + diff --git a/cli/golem-cli/src/handler.rs b/cli/golem-cli/src/handler.rs new file mode 100644 index 0000000000..879fb7168d --- /dev/null +++ b/cli/golem-cli/src/handler.rs @@ -0,0 +1,161 @@ +use std::sync::Arc; + +use async_trait::async_trait; + +use rust_mcp_sdk::schema::{ + schema_utils::{ CallToolError, NotificationFromClient, RequestFromClient, ResultFromServer }, + ClientRequest, + ListToolsResult, + RpcError, +}; +use rust_mcp_sdk::{ + mcp_server::{ enforce_compatible_protocol_version, ServerHandlerCore }, + McpServer, +}; + +use crate::tools::CoreTools; +use crate::resources; + +pub struct MyServerHandler; + +// To check out a list of all the methods in the trait that you can override, take a look at +// https://github.com/rust-mcp-stack/rust-mcp-sdk/blob/main/crates/rust-mcp-sdk/src/mcp_handlers/mcp_server_handler_core.rs +#[allow(unused)] +#[async_trait] +impl ServerHandlerCore for MyServerHandler { + // Process incoming requests from the client + async fn handle_request( + &self, + request: RequestFromClient, + runtime: Arc + ) -> std::result::Result { + let method_name = &request.method().to_owned(); + match request { + //Handle client requests according to their specific type. + RequestFromClient::ClientRequest(client_request) => + match client_request { + // Handle the initialization request + ClientRequest::InitializeRequest(initialize_request) => { + let mut server_info = runtime.server_info().to_owned(); + + if + let Some(updated_protocol_version) = + enforce_compatible_protocol_version( + &initialize_request.params.protocol_version, + &server_info.protocol_version + ).map_err(|err| { + tracing::error!( + "Incompatible protocol version :\nclient: {}\nserver: {}", + &initialize_request.params.protocol_version, + &server_info.protocol_version + ); + RpcError::internal_error().with_message(err.to_string()) + })? + { + server_info.protocol_version = updated_protocol_version; + } + + return Ok(server_info.into()); + } + // Handle ListToolsRequest, return list of available tools + ClientRequest::ListToolsRequest(_) => + Ok( + (ListToolsResult { + meta: None, + next_cursor: None, + tools: CoreTools::tools(), + }).into() + ), + + // Handles incoming CallToolRequest and processes it using the appropriate tool. + ClientRequest::CallToolRequest(request) => { + let tool_name = request.tool_name().to_string(); + + // Attempt to convert request parameters into CoreTools enum + let tool_params = CoreTools::try_from(request.params).map_err(|_| + CallToolError::unknown_tool(tool_name.clone()) + )?; + + // Match the tool variant and execute its corresponding logic + let result = match tool_params { + CoreTools::ListToolsTool(list_tools_tool) => { + list_tools_tool + .call_tool().await + .map_err(|err| { + RpcError::internal_error().with_message(err.to_string()) + })? + } + CoreTools::CallToolTool(call_tool_tool) => { + call_tool_tool + .call_tool().await + .map_err(|err| { + RpcError::internal_error().with_message(err.to_string()) + })? + } + CoreTools::ListResourcesTool(list_resources_tool) => { + list_resources_tool + .call_tool().await + .map_err(|err| { + RpcError::internal_error().with_message(err.to_string()) + })? + } + }; + Ok(result.into()) + } + + ClientRequest::ListResourcesRequest(_req) => { + // you can pass Some(cwd) if you want to honor a configured root + Ok(resources::list_resources_from_manifests(None).into()) + } + + ClientRequest::ReadResourceRequest(req) => { + match resources::read_manifest_resource(&req.params.uri) { + Some(res) => Ok(res.into()), + None => + Err( + RpcError::invalid_params().with_message( + format!( + "Unknown or unreadable resource URI: {}", + req.params.uri + ) + ) + ), + } + } + + // Return Method not found for any other requests + _ => + Err( + RpcError::method_not_found().with_message( + format!("No handler is implemented for '{method_name}'.") + ) + ), + } + // Handle custom requests + RequestFromClient::CustomRequest(_) => + Err( + RpcError::method_not_found().with_message( + "No handler is implemented for custom requests.".to_string() + ) + ), + } + } + + // Process incoming client notifications + async fn handle_notification( + &self, + notification: NotificationFromClient, + _: Arc + ) -> std::result::Result<(), RpcError> { + Ok(()) + } + + // Process incoming client errors + async fn handle_error( + &self, + error: &RpcError, + _: Arc + ) -> std::result::Result<(), RpcError> { + Ok(()) + } +} diff --git a/cli/golem-cli/src/main.rs b/cli/golem-cli/src/main.rs index 373e926ed6..7121cfd892 100644 --- a/cli/golem-cli/src/main.rs +++ b/cli/golem-cli/src/main.rs @@ -12,43 +12,110 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::hooks::NoHooks; -use golem_cli::command_handler::CommandHandler; +use std::ffi::OsString; use std::process::ExitCode; use std::sync::Arc; +use golem_cli::command_handler::{ CommandHandler, CommandHandlerHooks }; + +#[cfg(feature = "server-commands")] +//mod serve; // MCP HTTP server lives in src/serve.rs +mod handler; +mod tools; +mod resources; + + +#[cfg(feature = "server-commands")] +static SERVE_ARGS: std::sync::OnceLock = std::sync::OnceLock::new(); + +// ----------------------------------------------------------------------------- +// Hooks +// ----------------------------------------------------------------------------- #[cfg(feature = "server-commands")] mod hooks { use golem_cli::command::server::ServerSubcommand; use golem_cli::command_handler::CommandHandlerHooks; use golem_cli::context::Context; - use clap_verbosity_flag::Verbosity; use std::sync::Arc; - pub struct NoHooks {} + // Bring in the MCP types **inside** the module + use crate::handler::MyServerHandler; // sibling module + use rust_mcp_sdk::event_store::InMemoryEventStore; + use rust_mcp_sdk::mcp_server::{ hyper_server_core, HyperServerOptions }; + use rust_mcp_sdk::schema::{ + Implementation, + InitializeResult, + ServerCapabilities, + ServerCapabilitiesTools, + ServerCapabilitiesResources, + LATEST_PROTOCOL_VERSION, + }; + + pub struct NoHooks; impl CommandHandlerHooks for NoHooks { - #[cfg(feature = "server-commands")] - async fn handler_server_commands( + fn handler_server_commands( &self, _ctx: Arc, - _subcommand: ServerSubcommand, - ) -> anyhow::Result<()> { - unimplemented!() + _subcommand: ServerSubcommand + ) -> impl std::future::Future> { + async { Ok(()) } } - #[cfg(feature = "server-commands")] - async fn run_server() -> anyhow::Result<()> { - unimplemented!() + fn run_server() -> impl std::future::Future> + Send { + async move { + // Pull the args parsed in main(); fall back to the same defaults the parser used. + let args = crate::SERVE_ARGS.get().cloned().unwrap_or_default(); + + // STEP 1: Define server details and capabilities + let server_details = InitializeResult { + // server name and version + server_info: Implementation { + name: "Golem MCP Server Streamable HTTP + SSE".to_string(), + version: "0.1.0".to_string(), + title: Some("Golem MCP Server Streamable HTTP + SSE".to_string()), + }, + capabilities: ServerCapabilities { + // indicates that server support mcp tools + tools: Some(ServerCapabilitiesTools { list_changed: None }), + resources: Some(ServerCapabilitiesResources { list_changed: None,subscribe: None }), + ..Default::default() // Using default values for other fields + }, + meta: None, + instructions: Some("server instructions...".to_string()), + protocol_version: LATEST_PROTOCOL_VERSION.to_string(), + }; + + // STEP 2: instantiate our custom handler for handling MCP messages + let handler = MyServerHandler {}; + + // STEP 3: create a MCP server + let server = hyper_server_core::create_server( + server_details, + handler, + HyperServerOptions { + port: args.port, + sse_support: true, + event_store: Some(Arc::new(InMemoryEventStore::default())), // enable resumability + ..Default::default() + } + ); + + // STEP 4: Start the server + // after + if let Err(e) = server.start().await { + // keep the error context, but avoid requiring Sync + return Err(anyhow::anyhow!("MCP server failed to start: {e:?}")); + } + Ok(()) + } } - #[cfg(feature = "server-commands")] fn override_verbosity(verbosity: Verbosity) -> Verbosity { verbosity } - #[cfg(feature = "server-commands")] fn override_pretty_mode() -> bool { false } @@ -58,19 +125,99 @@ mod hooks { #[cfg(not(feature = "server-commands"))] mod hooks { use golem_cli::command_handler::CommandHandlerHooks; + pub struct NoHooks; + impl CommandHandlerHooks for NoHooks {} +} - pub struct NoHooks {} +use hooks::NoHooks; - impl CommandHandlerHooks for NoHooks {} +// ----------------------------------------------------------------------------- +// Minimal serve flag handling +// ----------------------------------------------------------------------------- +#[derive(Debug, Clone, Default)] +struct ServeArgs { + enable: bool, + port: u16, } +fn parse_and_strip_serve(argv: &[OsString]) -> (ServeArgs, Vec) { + let mut forwarded: Vec = Vec::with_capacity(argv.len()); + if let Some(first) = argv.first() { + forwarded.push(first.clone()); + } + + let mut i = 1usize; + let mut enable = false; + let mut port: u16 = 1232; + + while i < argv.len() { + let s = argv[i].to_string_lossy(); + + if s == "--serve" { + enable = true; + i += 1; + continue; + } + + if s == "--serve-port" && i + 1 < argv.len() { + if let Ok(p) = argv[i + 1].to_string_lossy().parse::() { + port = p; + i += 2; + continue; + } + } + + forwarded.push(argv[i].clone()); + i += 1; + } + + (ServeArgs { enable, port }, forwarded) +} + +#[cfg(feature = "server-commands")] +fn init_logging_once() { + use tracing_subscriber::{ fmt, EnvFilter }; + let _ = fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")) + ) + .try_init(); +} + +// ----------------------------------------------------------------------------- +// Main +// ----------------------------------------------------------------------------- fn main() -> ExitCode { - tokio::runtime::Builder::new_multi_thread() + let argv: Vec = std::env::args_os().collect(); + let (serve_args, forwarded) = parse_and_strip_serve(&argv); + + #[cfg(feature = "server-commands")] + if serve_args.enable { + init_logging_once(); + // Make the parsed args available to the hook + let _ = SERVE_ARGS.set(serve_args.clone()); + + return tokio::runtime::Builder + ::new_multi_thread() + .enable_all() + .build() + .expect("Failed to build tokio runtime for serve mode") + .block_on(async { + match NoHooks::run_server().await { + Ok(_) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("golem-cli: MCP server error: {e:#}"); + ExitCode::FAILURE + } + } + }); + } + + // Default: old CLI behavior + tokio::runtime::Builder + ::new_multi_thread() .enable_all() .build() .expect("Failed to build tokio runtime for golem-cli main") - .block_on(CommandHandler::handle_args( - std::env::args_os(), - Arc::new(NoHooks {}), - )) + .block_on(CommandHandler::handle_args(forwarded.into_iter(), Arc::new(NoHooks {}))) } diff --git a/cli/golem-cli/src/resources.rs b/cli/golem-cli/src/resources.rs new file mode 100644 index 0000000000..c4f6ee688e --- /dev/null +++ b/cli/golem-cli/src/resources.rs @@ -0,0 +1,116 @@ +use rust_mcp_sdk::schema::{ + ListResourcesResult, Resource, + ReadResourceResult, ReadResourceResultContentsItem, TextResourceContents, +}; + +use std::path::{Path, PathBuf}; + + +use serde_json::Map; +use serde_json::Value; + +/// Build the MCP `resources/list` result from the manifest discovery. +pub fn list_resources_from_manifests(cwd: Option<&str>) -> ListResourcesResult { + let tree = crate::tools::discover_manifest_tree(cwd); + + let root = cwd + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + + let mut out: Vec = Vec::new(); + flatten_tree_into_resources(&tree, &root, &mut Vec::new(), &mut out); + + ListResourcesResult { resources: out, next_cursor: None, meta: None } +} + +/// Resolve and read a single resource by its file:// URI (YAML/YML). +pub fn read_manifest_resource(uri: &str) -> Option { + let path = uri.strip_prefix("file://")?; + let text = std::fs::read_to_string(path).ok()?; // load the YAML/YML content + + let item = ReadResourceResultContentsItem::TextResourceContents( + TextResourceContents { + uri: uri.to_string(), + mime_type: Some("application/yaml".to_string()), // or detect yml/yaml + text, // << required + meta: None, + }, + ); + + Some(ReadResourceResult { + contents: vec![item], + meta: None, + }) +} + +// ---------- helpers ---------- + +fn flatten_tree_into_resources( + node: &serde_json::Value, + root: &Path, + segments: &mut Vec, + out: &mut Vec, +) { + match node { + serde_json::Value::String(filename) => { + let mut full = PathBuf::from(root); + for seg in segments.iter() { full.push(seg); } + full.push(filename); + + let abs = full.canonicalize().unwrap_or(full.clone()); + push_manifest_resource(out, &abs, segments); + } + serde_json::Value::Object(map) => { + for (k, v) in map { + segments.push(k.clone()); + flatten_tree_into_resources(v, root, segments, out); + segments.pop(); + } + } + _ => {} + } +} + + +fn mime_for_path(p: &std::path::Path) -> String { + match p.extension().and_then(|s| s.to_str()).map(|s| s.to_ascii_lowercase()) { + Some(ext) if ext == "yaml" || ext == "yml" => "application/yaml".to_string(), + _ => "text/plain".to_string(), + } +} + + +fn push_manifest_resource( + out: &mut Vec, + abs_path: &std::path::Path, + logical_dirs: &[String], +) { + let file_name = abs_path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("manifest.yaml") + .to_string(); + + let uri = format!("file://{}", abs_path.display()); + + let description = if logical_dirs.is_empty() { + format!("Manifest file {}", file_name) + } else { + format!("Manifest for {}", logical_dirs.join("/")) + }; + + let size = std::fs::metadata(abs_path).ok().and_then(|m| m.len().try_into().ok()); + + out.push(Resource { + uri, + name: file_name.clone(), + title: Some(file_name.clone()), + description: Some(description), + mime_type: Some(mime_for_path(abs_path)), // "application/yaml" for yml/yaml + // extra fields your SDK expects: + annotations: None, // or Some(Annotations { audience: vec![], last_modified: None, priority: None }) + meta: None::>, + size, // Option + // if your struct has any additional required field, add it here similarly. + }); +} \ No newline at end of file diff --git a/cli/golem-cli/src/tools.rs b/cli/golem-cli/src/tools.rs new file mode 100644 index 0000000000..55ca851652 --- /dev/null +++ b/cli/golem-cli/src/tools.rs @@ -0,0 +1,845 @@ +use std::path::{ Path, PathBuf }; +use std::process::Stdio; + +use rust_mcp_sdk::schema::{ schema_utils::CallToolError, CallToolResult, TextContent }; +use rust_mcp_sdk::{ macros::{ mcp_tool, JsonSchema }, tool_box }; + +use serde::{ Deserialize, Serialize }; +use tokio::{ + io::{ AsyncBufReadExt, BufReader }, + process::Command, + task::JoinSet, + time::{ sleep, Duration }, +}; +use std::collections::BTreeMap; +use std::fs; +use std::sync::Arc; +use std::sync::OnceLock; // Rust std version of OnceCell +use tokio::sync::Mutex; + +static GOLEM_LOGS: OnceLock>>> = OnceLock::new(); + +// ====================================================================================== +// Local types +// ====================================================================================== + +#[derive(Serialize, Clone)] +struct ToolResources { + #[serde(rename = "relevant_repos")] + pub relevant_repos: Vec, + pub docs: Vec, +} + +#[derive(Serialize, Clone)] +struct DocLink { + pub title: String, + pub url: String, + pub score: f32, +} + +#[derive(Serialize, Clone)] +struct RepoCrateResource { + pub repo: String, + #[serde(rename = "cargo_toml")] + pub cargo_toml: serde_json::Value, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub modules: Vec, +} + +#[derive(Serialize, Clone)] +struct SubcommandDescriptor { + pub name: String, + #[serde(skip_serializing_if = "String::is_empty")] + pub description: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subcommands: Vec, + #[serde(skip_serializing_if = "String::is_empty")] + pub usage: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub arguments: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub options: BTreeMap, +} + +#[derive(Serialize, Clone)] +struct ToolDescriptor { + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subcommands: Vec, + pub other: ToolResources, +} + +#[derive(Serialize)] +struct ToolsListResult { + pub tools: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ExecutedCommand { + pub argv: Vec, + pub cwd: String, +} + +#[derive(Debug, Serialize)] +#[derive(Clone)] +struct LogLine { + pub stream: &'static str, // "stdout" | "stderr" + pub line: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ToolResult { + pub exit_code: i32, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallResultPayload { + ok: bool, + command: ExecutedCommand, + logs: Vec, + result: ToolResult, +} + +// ====================================================================================== +// Helpers +// ====================================================================================== + +fn clap_top_level_commands() -> std::collections::BTreeMap { + use clap::CommandFactory; + use golem_cli::command; + type Root = command::GolemCliCommand; + + let mut map = std::collections::BTreeMap::::new(); + for sc in Root::command().get_subcommands() { + let name = sc.get_name().to_string(); + let desc = sc + .get_about() + .or_else(|| sc.get_long_about()) + .map(|styled| styled.to_string()) + .unwrap_or_else(|| "".to_string()); + map.insert(name, desc); + } + map +} + +fn collect_subs_with_path(node: &clap::Command, _path: &Vec) -> Vec { + let mut out = Vec::new(); + for sc in node.get_subcommands() { + let name = sc.get_name().to_string(); + let desc = sc + .get_about() + .or_else(|| sc.get_long_about()) + .map(|s| s.to_string()) + .unwrap_or_default(); + + let subs = collect_subs_with_path(sc, &vec![]); + + // Usage comes from clap; if not a leaf we skip it (kept empty) + let usage = if subs.is_empty() { + // `render_usage()` needs &mut self → clone and make it mutable. + let mut tmp = sc.clone(); + tmp.render_usage().to_string() + } else { + String::new() + }; + + let (arguments, options) = if subs.is_empty() { + collect_maps_for(sc) + } else { + (BTreeMap::new(), BTreeMap::new()) + }; + + out.push(SubcommandDescriptor { + name, + description: desc, + subcommands: subs, + usage, + arguments, + options, + }); + } + out +} + +fn to_upper_snake(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + if ch == '-' || ch == ' ' { + out.push('_'); + } else { + out.push(ch.to_ascii_uppercase()); + } + } + out +} + +// Split command params into (positional arguments, options/flags) +fn collect_maps_for(node: &clap::Command) -> (BTreeMap, BTreeMap) { + let mut args = BTreeMap::new(); + let mut opts = BTreeMap::new(); + + for a in node.get_arguments() { + #[allow(deprecated)] + if a.is_hide_set() { + continue; + } + + let desc = a + .get_long_help() + .or_else(|| a.get_help()) + .map(|s| s.to_string()) + .unwrap_or_default(); + + // Heuristic for positional: has an index and no long/short names + let is_positional = + a.get_index().is_some() && a.get_long().is_none() && a.get_short().is_none(); + + if is_positional { + let key = to_upper_snake(a.get_id().as_str()); + if !key.is_empty() { + args.insert(key, desc); + } + } else { + let key = if let Some(long) = a.get_long() { + format!("--{}", long) + } else if let Some(short) = a.get_short() { + format!("-{}", short) + } else { + to_upper_snake(a.get_id().as_str()) + }; + if !key.is_empty() { + opts.insert(key, desc); + } + } + } + + (args, opts) +} + +fn clap_subcommands_for(cmd_name: &str) -> Vec { + use clap::CommandFactory; + use golem_cli::command; + type Root = command::GolemCliCommand; + + for sc in Root::command().get_subcommands() { + if sc.get_name() == cmd_name { + return collect_subs_with_path(sc, &vec![cmd_name.to_string()]); + } + } + Vec::new() +} + +pub async fn run_golem_process( + argv: Vec, + cwd: &Path +) -> Result { + let workdir = cwd.to_path_buf(); + + let (first, rest) = argv.split_first().ok_or_else(|| "empty argv".to_string())?; + + // --- Detached path: `golem server run ...` --- + let is_server_run = + first == "server" && + rest + .first() + .map(|s| s.as_str() == "run") + .unwrap_or(false); + + if is_server_run { + // --- Case 1: server already running (we have a buffer) --- + if let Some(buf) = GOLEM_LOGS.get() { + let logs_guard = buf.lock().await; + let logs_vec: Vec = logs_guard.clone(); // LogLine: Clone + + return Ok(ToolCallResultPayload { + ok: true, + command: ExecutedCommand { + argv: argv.clone(), + cwd: workdir.display().to_string(), + }, + logs: logs_vec, + result: ToolResult { exit_code: 0 }, + }); + } + + // --- Case 2: no server yet -> start it, capture logs --- + + let mut cmd = Command::new("golem"); + cmd.current_dir(&workdir); + cmd.args(std::iter::once(first).chain(rest.iter())); + + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + // IMPORTANT: don't set kill_on_drop(true) here – we want it to live + + let mut child = cmd.spawn().map_err(|e| format!("spawn `golem server run`: {e}"))?; + + // shared buffer of LogLine (stdout+stderr) + let buffer = Arc::new(Mutex::new(Vec::::new())); + let _ = GOLEM_LOGS.set(buffer.clone()); // ignore Err on race + + // --- stdout reader --- + if let Some(stdout) = child.stdout.take() { + let buf_clone = buffer.clone(); + tokio::spawn(async move { + use tokio::io::{ AsyncBufReadExt, BufReader }; + let mut reader = BufReader::new(stdout).lines(); + + while let Ok(Some(line)) = reader.next_line().await { + // mirror to terminal + println!("{line}"); + // store + buf_clone.lock().await.push(LogLine { + stream: "stdout", + line, + }); + } + }); + } + + // --- stderr reader (THIS is where most logs probably are) --- + if let Some(stderr) = child.stderr.take() { + let buf_clone = buffer.clone(); + tokio::spawn(async move { + use tokio::io::{ AsyncBufReadExt, BufReader }; + let mut reader = BufReader::new(stderr).lines(); + + while let Ok(Some(line)) = reader.next_line().await { + // also mirror to terminal (optional: prefix with "ERR: ") + eprintln!("{line}"); + // store + buf_clone.lock().await.push(LogLine { + stream: "stderr", + line, + }); + } + }); + } + + // avoid zombies: wait on child in background, but we never block on it here + tokio::spawn(async move { + let _ = child.wait().await; + }); + + // ️Wait a bit so startup logs have time to arrive + // Golem startup logs often take >1s, so let's wait up to ~5s for *any* log. + let timeout = Duration::from_secs(5); + let start = std::time::Instant::now(); + + loop { + { + let logs_guard = buffer.lock().await; + if !logs_guard.is_empty() { + break; + } + } + if start.elapsed() >= timeout { + break; + } + sleep(Duration::from_millis(100)).await; + } + + // Return whatever we've collected so far (maybe still empty if it stayed silent) + let logs_guard = buffer.lock().await; + let logs_vec: Vec = logs_guard.clone(); + + return Ok(ToolCallResultPayload { + ok: true, + command: ExecutedCommand { + argv: argv.clone(), + cwd: workdir.display().to_string(), + }, + logs: logs_vec, + result: ToolResult { exit_code: 0 }, + }); + } + + // --- End detached path --- + + // --- Semi-attached path: `golem agent stream ` --- + + let is_agent_stream = + first == "agent" && + rest + .first() + .map(|s| s.as_str() == "stream") + .unwrap_or(false); + + if is_agent_stream { + // This command streams forever. We want to capture some logs, then return. + let mut cmd = Command::new("golem"); + cmd.current_dir(&workdir); + cmd.args(std::iter::once(first).chain(rest.iter())); + cmd.kill_on_drop(true); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| format!("spawn golem agent stream: {e}"))?; + + let mut logs: Vec = Vec::new(); + let mut set = JoinSet::new(); + + if let Some(out) = child.stdout.take() { + let mut reader = BufReader::new(out).lines(); + set.spawn(async move { + let mut lines: Vec = Vec::new(); + while let Ok(Some(line)) = reader.next_line().await { + lines.push(LogLine { stream: "stdout", line }); + } + lines + }); + } + + if let Some(err) = child.stderr.take() { + let mut reader = BufReader::new(err).lines(); + set.spawn(async move { + let mut lines: Vec = Vec::new(); + while let Ok(Some(line)) = reader.next_line().await { + lines.push(LogLine { stream: "stderr", line }); + } + lines + }); + } + + // How long we let it stream before we cut it off. + // Adjust this as you like (seconds, lines, etc.). + let timeout = Duration::from_secs(5); + + let status_opt = match tokio::time::timeout(timeout, child.wait()).await { + Ok(res) => Some(res.map_err(|e| format!("wait: {e}"))?), + Err(_) => { + // Timed out: stop the streaming process so our readers finish. + let _ = child.kill().await; + None + } + }; + + // Drain the reader tasks (they stop when pipes close). + while let Some(joined) = set.join_next().await { + if let Ok(mut part) = joined { + logs.append(&mut part); + } + } + + let (ok, exit_code) = match status_opt { + Some(status) => (status.success(), status.code().unwrap_or(-1)), + // Timed out but streaming was fine; treat as "launched OK". + None => (true, 0), + }; + + return Ok(ToolCallResultPayload { + ok, + command: ExecutedCommand { + argv: argv.clone(), + cwd: workdir.display().to_string(), + }, + logs, + result: ToolResult { exit_code }, + }); + } + + // --- End Semi-attached path + + // --- Semi-detached path: `golem api definition swagger ...` --- + + let is_swagger_ui = + first == "api" + && rest + .get(0) + .map(|s| s.as_str() == "definition") + .unwrap_or(false) + && rest + .get(1) + .map(|s| s.as_str() == "swagger") + .unwrap_or(false); + + if is_swagger_ui { + use tokio::io::{AsyncBufReadExt, BufReader}; + use tokio::time::sleep; + + // We WANT this process to live beyond the request, + // so do NOT set kill_on_drop(true). + let mut cmd = Command::new("golem"); + cmd.current_dir(&workdir); + cmd.args(std::iter::once(first).chain(rest.iter())); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("spawn `golem api definition swagger`: {e}"))?; + + // Local buffer for this swagger invocation + let buffer = Arc::new(Mutex::new(Vec::::new())); + + // stdout reader + if let Some(stdout) = child.stdout.take() { + let buf_clone = buffer.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + println!("{line}"); + buf_clone.lock().await.push(LogLine { + stream: "stdout".into(), + line, + }); + } + }); + } + + // stderr reader + if let Some(stderr) = child.stderr.take() { + let buf_clone = buffer.clone(); + tokio::spawn(async move { + let mut reader = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = reader.next_line().await { + eprintln!("{line}"); + buf_clone.lock().await.push(LogLine { + stream: "stderr".into(), + line, + }); + } + }); + } + + // Reap child in background to avoid zombies + tokio::spawn(async move { + let _ = child.wait().await; + }); + + // Give Swagger some time to start and print: + // Selected profile: local + // Browser opened successfully. + // ╔═ + // ║ Swagger UI running at http://localhost:9007 + // ║ API is deployed at 1 locations + // ╚═ + let timeout = Duration::from_secs(5); // tweak as needed + sleep(timeout).await; + + let logs = { + let guard = buffer.lock().await; + guard.clone() + }; + + return Ok(ToolCallResultPayload { + ok: true, // we successfully launched swagger + command: ExecutedCommand { + argv: argv.clone(), + cwd: workdir.display().to_string(), + }, + logs, + // This is a "launch" result; the server is still running, + // so we don't have a real exit code yet. + result: ToolResult { exit_code: 0 }, + }); + } + + // --- End swagger semi-detached path --- + + + // Generic (attached) path: capture output and wait for completion. + let mut cmd = Command::new("golem"); + cmd.current_dir(&workdir); + cmd.args(std::iter::once(first).chain(rest.iter())); + cmd.kill_on_drop(true); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| format!("spawn golem: {e}"))?; + + let mut logs: Vec = Vec::new(); + let mut set = JoinSet::new(); + + if let Some(out) = child.stdout.take() { + let mut reader = BufReader::new(out).lines(); + set.spawn(async move { + let mut lines: Vec = Vec::new(); + while let Ok(Some(line)) = reader.next_line().await { + lines.push(LogLine { stream: "stdout", line }); + } + lines + }); + } + + if let Some(err) = child.stderr.take() { + let mut reader = BufReader::new(err).lines(); + set.spawn(async move { + let mut lines: Vec = Vec::new(); + while let Ok(Some(line)) = reader.next_line().await { + lines.push(LogLine { stream: "stderr", line }); + } + lines + }); + } + + let status = child.wait().await.map_err(|e| format!("wait: {e}"))?; + while let Some(joined) = set.join_next().await { + if let Ok(mut part) = joined { + logs.append(&mut part); + } + } + + let exit_code = status.code().unwrap_or(-1); + + Ok(ToolCallResultPayload { + ok: exit_code == 0, + command: ExecutedCommand { + argv, + cwd: workdir.display().to_string(), + }, + logs, + result: ToolResult { exit_code }, + }) +} + +/// Always return Ok(CallToolResult) with JSON string content. +/// If serialization fails, we still return a JSON error string instead of Err(CallToolError). +fn json_ok(val: &T) -> Result { + let s = match serde_json::to_string(val) { + Ok(s) => s, + Err(e) => format!(r#"{{"ok":false,"error":"serialization error: {}"}}"#, e), + }; + Ok(CallToolResult::text_content(vec![TextContent::new(s, None, None)])) +} + +// ====================================================================================== +// ListToolsTool +// ====================================================================================== + +#[mcp_tool(name = "list_tools", description = "List available golem subcommands with metadata.")] +#[derive(Debug, ::serde::Deserialize, ::serde::Serialize, JsonSchema)] +pub struct ListToolsTool {} + +impl ListToolsTool { + pub async fn call_tool(&self) -> Result { + let cmds_with_descs: Vec<(String, String)> = clap_top_level_commands() + .into_iter() + .collect(); + + let mut tools: Vec = Vec::new(); + for (cmd_name, desc) in cmds_with_descs.into_iter() { + let subs = clap_subcommands_for(&cmd_name); + tools.push(ToolDescriptor { + name: cmd_name, + description: desc, + subcommands: subs, + other: ToolResources { + relevant_repos: vec![], + docs: vec![], + }, + }); + } + + json_ok(&(ToolsListResult { tools })) + } +} + +// ====================================================================================== +// CallToolTool +// ====================================================================================== + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +pub struct GolemRunInput { + pub args: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, +} + +#[mcp_tool(name = "call_tool", description = "Execute a golem subcommand with arguments.")] +#[derive(Debug, ::serde::Deserialize, ::serde::Serialize, JsonSchema)] +pub struct CallToolTool { + pub name: String, + pub arguments: GolemRunInput, +} + +impl CallToolTool { + pub async fn call_tool(&self) -> Result { + // Build argv: [name, ...args] + let mut argv = Vec::with_capacity(1 + self.arguments.args.len()); + argv.push(self.name.clone()); + argv.extend(self.arguments.args.clone()); + + // Resolve cwd without generating CallToolError + let cwd = self.arguments.cwd + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + + match run_golem_process(argv, &cwd).await { + Ok(payload) => json_ok(&payload), + Err(msg) => { + let fallback = ToolCallResultPayload { + ok: false, + command: ExecutedCommand { + argv: vec![self.name.clone()], + cwd: cwd.display().to_string(), + }, + logs: vec![LogLine { + stream: "stderr", + line: msg, + }], + result: ToolResult { exit_code: -1 }, + }; + json_ok(&fallback) + } + } + } +} + +// ====================================================================================== +// ListResourcesTool +// ====================================================================================== + +#[mcp_tool( + name = "list_resources", + description = "Recursively list directories that contain YAML manifests. Returns a nested map where each dir either maps to a manifest filename (if present directly) or to child-dir maps." +)] +#[derive(Debug, ::serde::Deserialize, ::serde::Serialize, JsonSchema)] +pub struct ListResourcesTool { + /// Optional working directory to start from. If not provided, uses the process current dir, you may need to supply (switch to such directory) this if such command fails. + #[serde(default)] + pub cwd: Option, +} + +impl ListResourcesTool { + pub async fn call_tool(&self) -> Result { + // Reuse your shared crawler; keeps output EXACTLY as before: + let v = crate::tools::discover_manifest_tree(self.cwd.as_deref()); + // Wrap as a JSON tool result using your existing helper + Ok(crate::tools::json_ok(&v)?) + } +} + +// --- NEW: tiny public helpers --------------------------- + +fn is_yaml(p: &Path) -> bool { + match p.extension().and_then(|s| s.to_str()) { + Some(ext) => { + let ext = ext.to_ascii_lowercase(); + ext == "yaml" || ext == "yml" + } + None => false, + } +} + +/// Build the nested manifest tree for a given directory. +/// Returns a serde_json::Value shaped like: +/// {"example":"a.yaml","nested":{"child":"b.yml"}, ...} +fn build_tree(dir: &Path) -> Option { + let mut direct_manifests: Vec = Vec::new(); + let mut child_map: BTreeMap = BTreeMap::new(); + + let entries = match fs::read_dir(dir) { + Ok(rd) => rd, + Err(_) => { + return None; + } + }; + + // Collect first for stable output + let mut files: Vec = Vec::new(); + let mut dirs: Vec = Vec::new(); + for e in entries.filter_map(|e| e.ok()) { + let p = e.path(); + if p.is_dir() { + // skip hidden dirs + if let Some(name) = p.file_name().and_then(|s| s.to_str()) { + if name.starts_with('.') { + continue; + } + } + dirs.push(p); + } else { + files.push(p); + } + } + files.sort(); + dirs.sort(); + + for p in files { + if is_yaml(&p) { + if let Some(name) = p.file_name().and_then(|s| s.to_str()) { + direct_manifests.push(name.to_string()); + } + } + } + + for d in dirs { + if let Some(child_val) = build_tree(&d) { + if let Some(name) = d.file_name().and_then(|s| s.to_str()) { + child_map.insert(name.to_string(), child_val); + } + } + } + + if !child_map.is_empty() { + Some(serde_json::to_value(child_map).ok()?) + } else if let Some(first) = direct_manifests.first() { + Some(serde_json::Value::String(first.clone())) + } else { + None + } +} + +/// NEW: expose the top-level discovery as a public helper. +/// Keeps your original JSON *exactly as-is*. +pub fn discover_manifest_tree(cwd: Option<&str>) -> serde_json::Value { + let root = cwd + .map(PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + + let mut top_map: BTreeMap = BTreeMap::new(); + + let rd = match fs::read_dir(&root) { + Ok(rd) => rd, + Err(e) => { + return serde_json::json!({ + "ok": false, + "error": format!("failed to read dir {}: {e}", root.display()) + }); + } + }; + + let mut dirs: Vec = Vec::new(); + for e in rd.filter_map(|e| e.ok()) { + let p = e.path(); + if p.is_dir() { + if let Some(name) = p.file_name().and_then(|s| s.to_str()) { + if name.starts_with('.') { + continue; + } + } + dirs.push(p); + } + } + dirs.sort(); + + for d in dirs { + if let Some(val) = build_tree(&d) { + if let Some(name) = d.file_name().and_then(|s| s.to_str()) { + top_map.insert(name.to_string(), val); + } + } + } + + serde_json + ::to_value(top_map) + .unwrap_or_else(|e| { + serde_json::json!({ "ok": false, "error": format!("serialization error: {e}") }) + }) +} + +// ====================================================================================== +// CoreTools enum +// ====================================================================================== + +tool_box!(CoreTools, [ListToolsTool, CallToolTool, ListResourcesTool]); diff --git a/cli/golem-cli/test-data/mcp-client/example-plugin/icon.png b/cli/golem-cli/test-data/mcp-client/example-plugin/icon.png new file mode 100644 index 0000000000..8feac43b28 Binary files /dev/null and b/cli/golem-cli/test-data/mcp-client/example-plugin/icon.png differ diff --git a/cli/golem-cli/test-data/mcp-client/example-plugin/manifest.yaml b/cli/golem-cli/test-data/mcp-client/example-plugin/manifest.yaml new file mode 100644 index 0000000000..1db706ea90 --- /dev/null +++ b/cli/golem-cli/test-data/mcp-client/example-plugin/manifest.yaml @@ -0,0 +1,11 @@ +# plugin-manifest.yaml + +name: my-awesome-plugin-app +version: 1.0.0 +description: "Simple App plugin that points to a WASM component" +icon: ./cli/golem-cli/test-data/mcp-client/example-plugin/icon.png +homepage: https://example.com/my-awesome-plugin + +specs: + type: App + component: ./mytestapp/golem-temp/agents/pack_mycomponent.wasm diff --git a/cli/golem-cli/tests/mcp-client/Cargo.toml b/cli/golem-cli/tests/mcp-client/Cargo.toml new file mode 100644 index 0000000000..c05ec05725 --- /dev/null +++ b/cli/golem-cli/tests/mcp-client/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "golem-mcp-client" +version = "0.1.3" +edition = "2021" +publish = false +license = "MIT" + + +[dependencies] +rust-mcp-sdk = "0.7.2" + +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +async-trait = { workspace = true } +futures = { workspace = true } +thiserror = { workspace = true } +colored = "3.0.0" +tracing-subscriber = { workspace = true } +tracing = { workspace = true } +regex = "1" + diff --git a/cli/golem-cli/tests/mcp-client/src/handler.rs b/cli/golem-cli/tests/mcp-client/src/handler.rs new file mode 100644 index 0000000000..19360f6b87 --- /dev/null +++ b/cli/golem-cli/tests/mcp-client/src/handler.rs @@ -0,0 +1,10 @@ +use async_trait::async_trait; +use rust_mcp_sdk::mcp_client::ClientHandler; + +pub struct MyClientHandler; + +#[async_trait] +impl ClientHandler for MyClientHandler { + // To check out a list of all the methods in the trait that you can override, take a look at + // https://github.com/rust-mcp-stack/rust-mcp-sdk/blob/main/crates/rust-mcp-sdk/src/mcp_handlers/mcp_client_handler.rs +} diff --git a/cli/golem-cli/tests/mcp-client/src/inquiry_utils.rs b/cli/golem-cli/tests/mcp-client/src/inquiry_utils.rs new file mode 100644 index 0000000000..446c900110 --- /dev/null +++ b/cli/golem-cli/tests/mcp-client/src/inquiry_utils.rs @@ -0,0 +1,124 @@ +//! This module contains utility functions for querying and displaying server capabilities. + +use colored::Colorize; +use rust_mcp_sdk::schema::CallToolRequestParams; +use rust_mcp_sdk::McpClient; +use rust_mcp_sdk::{ error::SdkResult, mcp_client::ClientRuntime }; +use serde_json::json; +use std::sync::Arc; + +const GREY_COLOR: (u8, u8, u8) = (90, 90, 90); +const HEADER_SIZE: usize = 31; + +pub struct InquiryUtils { + pub client: Arc, +} + +impl InquiryUtils { + fn print_header(&self, title: &str) { + let pad = ((HEADER_SIZE as f32) / 2.0 + (title.len() as f32) / 2.0).floor() as usize; + println!("\n{}", "=".repeat(HEADER_SIZE).custom_color(GREY_COLOR)); + println!("{:>pad$}", title.custom_color(GREY_COLOR)); + println!("{}", "=".repeat(HEADER_SIZE).custom_color(GREY_COLOR)); + } + + fn print_list(&self, list_items: Vec<(String, String)>) { + list_items + .iter() + .enumerate() + .for_each(|(index, item)| { + println!("{}. {}: {}", index + 1, item.0.yellow(), item.1.cyan()); + }); + } + + pub fn print_server_info(&self) { + self.print_header("Server info"); + let server_version = self.client.server_version().unwrap(); + println!("{} {}", "Server name:".bold(), server_version.name.cyan()); + println!("{} {}", "Server version:".bold(), server_version.version.cyan()); + } + + pub fn print_server_capabilities(&self) { + self.print_header("Capabilities"); + let capability_vec = [ + ("tools", self.client.server_has_tools()), + ("prompts", self.client.server_has_prompts()), + ("resources", self.client.server_has_resources()), + ("logging", self.client.server_supports_logging()), + ("experimental", self.client.server_has_experimental()), + ]; + + capability_vec.iter().for_each(|(tool_name, opt)| { + println!( + "{}: {}", + tool_name.bold(), + opt + .map(|b| if b { "Yes" } else { "No" }) + .unwrap_or("Unknown") + .cyan() + ); + }); + } + + pub async fn print_tool_list(&self) -> SdkResult<()> { + // Return if the MCP server does not support tools + if !self.client.server_has_tools().unwrap_or(false) { + return Ok(()); + } + + let tools = self.client.list_tools(None).await?; + self.print_header("Tools"); + self.print_list( + tools.tools + .iter() + .map(|item| { (item.name.clone(), item.description.clone().unwrap_or_default()) }) + .collect() + ); + + Ok(()) + } + + pub async fn call_call_tool( + &self, + name: &str, + args: Vec, + cwd: Option + ) -> SdkResult { + // Friendly log + println!( + "{}", + format!( + "\nCalling the \"call_tool\" with name=\"{}\", args={:?}, cwd={:?} ...", + name, + args, + cwd + ).magenta() + ); + + // Build the tool parameters to match CallToolTool { name, arguments: GolemRunInput { args, cwd } } + let params = + json!({ + "name": name, + "arguments": { + "args": args, // Vec + "cwd": cwd // Option + } + }) + .as_object() + .unwrap() + .clone(); + + // Invoke the server tool named "call_tool" + let result = self.client.call_tool(CallToolRequestParams { + name: "call_tool".to_string(), + arguments: Some(params), + }).await?; + + // Retrieve and print the result content + let result_content = result.content.first().unwrap().as_text_content()?; + println!("{}", result_content.text.green()); + + Ok(result_content.text.clone()) + } + +} diff --git a/cli/golem-cli/tests/mcp-client/src/main.rs b/cli/golem-cli/tests/mcp-client/src/main.rs new file mode 100644 index 0000000000..e35c62b298 --- /dev/null +++ b/cli/golem-cli/tests/mcp-client/src/main.rs @@ -0,0 +1,723 @@ +mod handler; +mod inquiry_utils; + +use handler::MyClientHandler; + +use rust_mcp_sdk::error::SdkResult; +use rust_mcp_sdk::mcp_client::client_runtime; + +use rust_mcp_sdk::schema::{ + ClientCapabilities, + Implementation, + InitializeRequestParams, + LATEST_PROTOCOL_VERSION, +}; +use rust_mcp_sdk::{ McpClient, ClientSseTransport, ClientSseTransportOptions }; +use std::sync::Arc; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use regex::Regex; +use serde_json::Value; + +struct Cmd { + tool: &'static str, + args: Vec, + cwd: Option, +} + +use crate::inquiry_utils::InquiryUtils; + +const MCP_SERVER_URL: &str = "http://127.0.0.1:3001/mcp"; + +#[tokio::main] +async fn main() -> SdkResult<()> { + tracing_subscriber + ::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()) + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + // Step1 : Define client details and capabilities + let client_details: InitializeRequestParams = InitializeRequestParams { + capabilities: ClientCapabilities::default(), + client_info: Implementation { + name: "golem-mcp-client-sse".to_string(), + version: "0.1.0".to_string(), + title: Some("Golem MCP Client (SSE)".to_string()), + }, + protocol_version: LATEST_PROTOCOL_VERSION.into(), + }; + + // Step2 : Create a transport, with options to launch/connect to a MCP Server + // Assuming @modelcontextprotocol/server-everything is launched with sse argument and listening on port 3001 + let transport = ClientSseTransport::new(MCP_SERVER_URL, ClientSseTransportOptions::default())?; + + // STEP 3: instantiate our custom handler that is responsible for handling MCP messages + let handler = MyClientHandler {}; + + // STEP 4: create the client + let client = client_runtime::create_client(client_details, transport, handler); + + // STEP 5: start the MCP client + client.clone().start().await?; + + // You can utilize the client and its methods to interact with the MCP Server. + // The following demonstrates how to use client methods to retrieve server information, + // and print them in the terminal, set the log level, invoke a tool, and more. + + // Create a struct with utility functions for demonstration purpose, to utilize different client methods and display the information. + let utils = InquiryUtils { + client: Arc::clone(&client), + }; + + // Display server information (name and version) + utils.print_server_info(); + + // Display server capabilities + utils.print_server_capabilities(); + + // Display the list of tools available on the server + utils.print_tool_list().await?; + + // Call add tool, and print the result + let mut commands = vec![ + Cmd { + tool: "server", + args: vec!["run".into()], + cwd: None, + }, + Cmd { + tool: "app", + args: vec!["new".into(), "mytestapp".into(), "typescript".into()], + cwd: None, + }, + Cmd { + tool: "component", + args: vec!["new".into(), "typescript".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "app", + args: vec!["build".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "app", + args: vec!["deploy".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "app", + args: vec!["update-agents".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "app", + args: vec!["redeploy-agents".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "app", + args: vec!["list-agent-types".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "app", + args: vec!["diagnose".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "component", + args: vec!["templates".into()], + cwd: None, + }, + Cmd { + tool: "component", + args: vec!["build".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "component", + args: vec!["deploy".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "component", + args: vec!["add-dependency".into(), "--component-name".into(), "pack:mycomponent".into(), "--dependency-type".into(), "wasm-rpc".into(), "--target-component-name".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "component", + args: vec!["list".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "component", + args: vec!["get".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "component", + args: vec!["update-agents".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "component", + args: vec!["redeploy-agents".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "plugin", + args: vec!["register".into(), "cli/golem-cli/test-data/mcp-client/example-plugin/manifest.yaml".into()], + cwd: None, + }, + Cmd { + tool: "component", + args: vec!["plugin".into(), "install".into(), "pack:mycomponent".into(), "--plugin-name".into(), "my-awesome-plugin-app".into(), "--plugin-version".into(), "1.0.0".into(), "--priority".into(), "1".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "component", + args: vec!["plugin".into(), "get".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + } + ]; + + // call commands + let mut i = 0; + while i < commands.len() { + // Borrow the current command, then clone only the fields we need to own + let (tool, args, cwd) = { + let c = &commands[i]; + (c.tool, c.args.clone(), c.cwd.clone()) + }; + + let output = utils.call_call_tool(tool, args.clone(), cwd.clone()).await?; + + // Trying to get installation id from previous command get, so we can execute the update & uninstall commands + if tool == "component" && args.as_slice() == ["plugin", "get", "pack:mycomponent"] { + let ids = extract_installation_ids(&output); + if let Some(first_id) = ids.first() { + // installation_id = first_id.clone(); + + // Enqueue the update now that we have the installation id + commands.push(Cmd { + tool: "component", + args: vec![ + "plugin".into(), + "update".into(), + "--installation-id".into(), + first_id.clone(), + "--priority".into(), + "1".into(), + "pack:mycomponent".into() + ], + cwd: Some("mytestapp".into()), + }); + + // Enqueue the uninstall now that we have the installation id + commands.push(Cmd { + tool: "component", + args: vec![ + "plugin".into(), + "uninstall".into(), + "--installation-id".into(), + first_id.clone(), + "pack:mycomponent".into() + ], + cwd: Some("mytestapp".into()), + }); + } + } + + i += 1; + } + + commands = vec![ + Cmd { + tool: "component", + args: vec!["diagnose".into(), "pack:mycomponent".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["new".into(), r#"mycomponent/counter-agent("clean-agent")"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["invoke".into(), r#"counter-agent("clean-agent")"#.into(), r#"mycomponent/counter-agent.{increment}"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["get".into(), r#"counter-agent("clean-agent")"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["list".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["stream".into(), r#"counter-agent("clean-agent")"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["update".into(), r#"counter-agent("clean-agent")"#.into(), "manual".into(), "3".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["interrupt".into(), r#"counter-agent("clean-agent")"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["resume".into(), r#"counter-agent("clean-agent")"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["simulate-crash".into(), r#"counter-agent("clean-agent")"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["oplog".into(), r#"counter-agent("clean-agent")"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["revert".into(), r#"counter-agent("clean-agent")"#.into(),"--number-of-invocations".into(), "1".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["invoke".into(),"-t".into(), "-i".into(), "123".into(), r#"counter-agent("clean-agent")"#.into(), r#"mycomponent/counter-agent.{increment}"#.into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "agent", + args: vec!["cancel-invocation".into(), r#"counter-agent("clean-agent")"#.into(), "123".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["deploy".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["definition".into(), "deploy".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["definition".into(), "list".into()], + cwd: Some("mytestapp".into()), + } + ]; + + let mut i = 0; + while i < commands.len() { + // Borrow the current command, then clone only the fields we need to own + let (tool, args, cwd) = { + let c = &commands[i]; + (c.tool, c.args.clone(), c.cwd.clone()) + }; + + let output = utils.call_call_tool(tool, args.clone(), cwd.clone()).await?; + if tool == "api" && args.as_slice() == ["definition", "list"] { + if let Some((api_id, version)) = extract_api_id_and_version(&output) { + commands.push(Cmd { + tool: "api", + args: vec![ + "definition".into(), + "get".into(), + "--id".into(), + api_id.clone(), + "--version".into(), + version.clone() + ], + cwd: Some("mytestapp".into()), + }); + commands.push(Cmd { + tool: "api", + args: vec![ + "definition".into(), + "export".into(), + "--id".into(), + api_id.clone(), + "--version".into(), + version.clone() + ], + cwd: Some("mytestapp".into()), + }); + commands.push(Cmd { + tool: "api", + args: vec![ + "definition".into(), + "swagger".into(), + "--id".into(), + api_id.clone(), + "--version".into(), + version.clone() + ], + cwd: Some("mytestapp".into()), + }); + } + } + + i += 1; + } + + /* In some API calls, you may need to create new token in console.golem.cloud, then add Static auth in ./golem/config-v2.json, also create "the test project" or change it to your existing project */ + commands = vec![ + Cmd { + tool: "api", + args: vec!["deployment".into(), "deploy".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["deployment".into(), "list".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["deployment".into(), "get".into(), "localhost:9006".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["security-scheme".into(), "create".into(), "--provider-type".into(), "google".into(), "--client-id".into(), "REPLACE_WITH_GOOGLE_CLIENT_ID".into(), "--client-secret".into(), "REPLACE_WITH_GOOGLE_CLIENT_SECRET".into(), "--redirect-url".into(), "http://localhost:9006/auth/callback".into(), "--scope".into(), "openid,email,profile".into(), "my-security".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["security-scheme".into(), "get".into(), "my-security".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["cloud".into(), "domain".into(), "new".into(), "the test project".into(), "mytestdomain.com".into(), "--cloud".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "api", + args: vec!["cloud".into(), "domain".into(), "get".into(), "the test project".into(), "--cloud".into()], + cwd: Some("mytestapp".into()), + }, + Cmd { + tool: "plugin", + args: vec!["list".into()], + cwd: None, + }, + Cmd { + tool: "plugin", + args: vec!["get".into(), "my-awesome-plugin-app/1.0.0".into()], + cwd: None, + }, + Cmd { + tool: "plugin", + args: vec!["unregister".into(), "my-awesome-plugin-app/1.0.0".into()], + cwd: None, + }, + Cmd { + tool: "profile", + args: vec!["new".into(), "local2".into()], + cwd: None, + }, + Cmd { + tool: "profile", + args: vec!["list".into()], + cwd: None, + }, + Cmd { + tool: "profile", + args: vec!["switch".into(), "local2".into()], + cwd: None, + }, + Cmd { + tool: "profile", + args: vec!["config".into(), "local2".into(), "set-format".into(), "json".into()], + cwd: None, + }, + Cmd { + tool: "profile", + args: vec!["get".into(), "local2".into()], + cwd: None, + }, + Cmd { + tool: "profile", + args: vec!["switch".into(), "local".into()], + cwd: None, + }, + Cmd { + tool: "profile", + args: vec!["delete".into(), "local2".into()], + cwd: None, + }, + Cmd { + tool: "cloud", + args: vec!["account".into(), "new".into(), "Steve".into(), "steve@local".into()], + cwd: None, + } + ]; + + let mut i = 0; + while i < commands.len() { + // Borrow the current command, then clone only the fields we need to own + let (tool, args, cwd) = { + let c = &commands[i]; + (c.tool, c.args.clone(), c.cwd.clone()) + }; + + let output = utils.call_call_tool(tool, args.clone(), cwd.clone()).await?; + // Detect the `cloud account new ...` command that just ran + if tool == "cloud" && args.as_slice() == ["account", "new", "Steve", "steve@local"] { + if let Some(account_id) = extract_account_id(&output) { + // Push the follow-up command using the extracted account ID + commands.push(Cmd { + tool: "cloud", + args: vec!["account".into(), "get".into(), "--account-id".into(), account_id.clone()], + cwd: None, + }); + // Push the follow-up command using the extracted account ID + commands.push(Cmd { + tool: "cloud", + args: vec!["account".into(), "update".into(), "--account-id".into(), account_id.clone(), "Samuel".into(), "samuel@local".into()], + cwd: None, + }); + // Push the follow-up command using the extracted account ID + commands.push(Cmd { + tool: "cloud", + args: vec!["account".into(), "grant".into(), "get".into(), "--account-id".into(), account_id.clone()], + cwd: None, + }); + // Push the follow-up command using the extracted account ID + commands.push(Cmd { + tool: "cloud", + args: vec!["account".into(), "grant".into(), "new".into(), "--account-id".into(), account_id.clone(), "Admin".into()], + cwd: None, + }); + // Push the follow-up command using the extracted account ID + commands.push(Cmd { + tool: "cloud", + args: vec!["account".into(), "grant".into(), "delete".into(), "--account-id".into(), account_id.clone(), "Admin".into()], + cwd: None, + }); + } else { + eprintln!("Failed to extract Account ID from output:\n{output}"); + } + } + + i += 1; + } + + commands = vec![ + Cmd { + tool: "cloud", + args: vec!["project".into(), "new".into(), "the test project".into()], + cwd: None, + }, + Cmd { + tool: "cloud", + args: vec!["project".into(), "list".into()], + cwd: None, + }, + Cmd { + tool: "cloud", + args: vec!["project".into(), "get-default".into()], + cwd: None, + }, + Cmd { + tool: "cloud", + args: vec!["project".into(), "grant".into(), "the test project".into(), "samuel@local".into(), "--action".into(), "ViewComponent".into()], + cwd: None, + }, + Cmd { + tool: "cloud", + args: vec!["project".into(), "policy".into(), "new".into(), "main".into(), "--cloud".into()], + cwd: None, + }, + ]; + + let mut i = 0; + while i < commands.len() { + // Borrow the current command, then clone only the fields we need to own + let (tool, args, cwd) = { + let c = &commands[i]; + (c.tool, c.args.clone(), c.cwd.clone()) + }; + + let output = utils.call_call_tool(tool, args.clone(), cwd.clone()).await?; + // Detect the `cloud account new ...` command that just ran + if tool == "cloud" && args.as_slice() == ["project", "policy", "new", "main", "--cloud"] { + if let Some(policy_id) = extract_policy_id(&output) { + // Push the follow-up command using the extracted policy ID + commands.push(Cmd { + tool: "cloud", + args: vec!["project".into(), "policy".into(), "get".into(), policy_id, "--cloud".into()], + cwd: None, + }); + } else { + eprintln!("Failed to extract Policy ID from output:\n{output}"); + } + } + + i += 1; + } + + + commands = vec![ + Cmd { + tool: "cloud", + args: vec!["token".into(), "list".into()], + cwd: None, + }, + Cmd { + tool: "cloud", + args: vec!["token".into(), "list".into()], + cwd: None, + }, + Cmd { + tool: "cloud", + args: vec!["token".into(), "list".into()], + cwd: None, + }, + Cmd { + tool: "cloud", + args: vec!["token".into(), "new".into()], + cwd: None, + } + ]; + + + let mut i = 0; + while i < commands.len() { + // Borrow the current command, then clone only the fields we need to own + let (tool, args, cwd) = { + let c = &commands[i]; + (c.tool, c.args.clone(), c.cwd.clone()) + }; + + let output = utils.call_call_tool(tool, args.clone(), cwd.clone()).await?; + // Detect the `cloud account new ...` command that just ran + if tool == "cloud" && args.as_slice() == ["token", "new"] { + if let Some(token_id) = extract_token_id(&output) { + // Push the follow-up command using the extracted token ID + commands.push(Cmd { + tool: "cloud", + args: vec!["token".into(), "delete".into(), token_id], + cwd: None, + }); + } else { + eprintln!("Failed to extract Token ID from output:\n{output}"); + } + } + + i += 1; + } + + client.shut_down().await?; + + Ok(()) +} + +fn strip_ansi(s: &str) -> String { + // strips \x1b[...m sequences + let ansi = Regex::new(r"\x1B\[[0-?]*[ -/]*[@-~]").unwrap(); + ansi.replace_all(s, "").to_string() +} + +fn extract_installation_ids(output_json: &str) -> Vec { + let v: Value = match serde_json::from_str(output_json) { + Ok(v) => v, + Err(_) => { + return vec![]; + } + }; + + let uuid_re = Regex::new( + r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" + ).unwrap(); + + let mut ids = Vec::new(); + if let Some(logs) = v.get("logs").and_then(|x| x.as_array()) { + for log in logs { + if let Some(line) = log.get("line").and_then(|x| x.as_str()) { + let clean = strip_ansi(line); + for m in uuid_re.find_iter(&clean) { + ids.push(m.as_str().to_string()); + } + } + } + } + ids +} +fn extract_api_id_and_version(output_json: &str) -> Option<(String, String)> { + let v: Value = serde_json::from_str(output_json).ok()?; + let logs = v.get("logs")?.as_array()?; + + // capture two "columns": ID and Version + let re = Regex::new(r"^\s*\|\s*([^\|]+?)\s*\|\s*([^\|]+?)\s*\|").unwrap(); + + for log in logs { + let line = log + .get("line") + .and_then(|x| x.as_str()) + .unwrap_or(""); + let clean = strip_ansi(line); + let trimmed = clean.trim(); + + if trimmed.contains("ID") && trimmed.contains("Version") { + continue; // header + } + if trimmed.contains("---") { + continue; // separator + } + + if let Some(caps) = re.captures(trimmed) { + let api_id = caps[1].trim().to_string(); + let version = caps[2].trim().to_string(); + if !api_id.is_empty() && !version.is_empty() { + return Some((api_id, version)); + } + } + } + + None +} + +fn extract_account_id(output: &str) -> Option { + // Example line: + // "║ Account ID: 2ecb743f-0e70-4536-b80b-c0ec1a257175" + // This matches the UUID after `Account ID:` + let re = Regex::new(r"Account ID:\s*([0-9a-fA-F-]{36})").ok()?; + let caps = re.captures(output)?; + Some(caps[1].to_string()) +} + +fn extract_policy_id(output: &str) -> Option { + // Example line: + // "║ Policy ID: 2ecb743f-0e70-4536-b80b-c0ec1a257175" + // This matches the UUID after `Policy ID:` + let re = Regex::new(r"Policy ID:\s*([0-9a-fA-F-]{36})").ok()?; + let caps = re.captures(output)?; + Some(caps[1].to_string()) +} + +fn extract_token_id(output: &str) -> Option { + // Example line: + // "║ Token ID: 2ecb743f-0e70-4536-b80b-c0ec1a257175" + // This matches the UUID after `Token ID:` + let re = Regex::new(r"Token ID:\s*([0-9a-fA-F-]{36})").ok()?; + let caps = re.captures(output)?; + Some(caps[1].to_string()) +} diff --git a/golem-common/Cargo.toml b/golem-common/Cargo.toml index 20522d21d8..15f77fa89f 100644 --- a/golem-common/Cargo.toml +++ b/golem-common/Cargo.toml @@ -141,4 +141,4 @@ tracing-test = { workspace = true } [build-dependencies] shadow-rs = { workspace = true } -lenient_bool = { workspace = true } +lenient_bool = { workspace = true } \ No newline at end of file diff --git a/golem-component-compilation-service/Cargo.toml b/golem-component-compilation-service/Cargo.toml index 2cacc6d7b2..da1c927927 100644 --- a/golem-component-compilation-service/Cargo.toml +++ b/golem-component-compilation-service/Cargo.toml @@ -39,4 +39,4 @@ uuid = { workspace = true } wasmtime = { workspace = true } [dev-dependencies] -test-r = { workspace = true } +test-r = { workspace = true } \ No newline at end of file diff --git a/golem-registry-service/Cargo.toml b/golem-registry-service/Cargo.toml index b35aa8df45..9c298577a8 100644 --- a/golem-registry-service/Cargo.toml +++ b/golem-registry-service/Cargo.toml @@ -116,3 +116,4 @@ futures = { workspace = true } testcontainers = { workspace = true } testcontainers-modules = { workspace = true } test-r = { workspace = true } +tryhard = { workspace = true }