use serde::Deserialize; use std::collections::HashMap; use std::fs::File; use std::path::PathBuf; use std::sync::Arc; use std::{env, fs}; use rmcp::model::{CallToolRequestParams, CallToolResult, Tool}; use rmcp::object; use tokio_util::sync::CancellationToken; use goose::agents::extension::{Envs, ExtensionConfig}; use goose::agents::extension_manager::{ExtensionManager, ExtensionManagerCapabilities}; use goose::agents::GoosePlatform; use goose::model::ModelConfig; use test_case::test_case; use async_trait::async_trait; use goose::conversation::message::Message; use goose::providers::base::{ stream_from_single_message, MessageStream, Provider, ProviderDef, ProviderMetadata, }; use goose_providers::conversation::token_usage::{ProviderUsage, Usage}; use goose_providers::errors::ProviderError; use once_cell::sync::Lazy; use std::process::Command; #[derive(Deserialize)] struct CargoBuildMessage { reason: String, target: Target, executable: String, } #[derive(Deserialize)] struct Target { name: String, kind: Vec, } #[derive(Clone)] pub struct MockProvider { pub model_config: ModelConfig, } impl MockProvider { pub fn new(model_config: ModelConfig) -> Self { Self { model_config } } } impl ProviderDef for MockProvider { type Provider = Self; fn metadata() -> ProviderMetadata { ProviderMetadata::empty() } fn from_env( model: ModelConfig, _extensions: Vec, ) -> futures::future::BoxFuture<'static, anyhow::Result> { Box::pin(async move { Ok(Self::new(model)) }) } } #[async_trait] impl Provider for MockProvider { fn get_name(&self) -> &str { "mock" } async fn stream( &self, _model_config: &ModelConfig, _session_id: &str, _system: &str, _messages: &[Message], _tools: &[Tool], ) -> Result { let message = Message::assistant().with_text("\"So we beat on, boats against the current, borne back ceaselessly into the past.\" — F. Scott Fitzgerald, The Great Gatsby (1925)"); let usage = ProviderUsage::new("mock".to_string(), Usage::default()); Ok(stream_from_single_message(message, usage)) } fn get_model_config(&self) -> ModelConfig { self.model_config.clone() } } fn build_and_get_binary_path() -> PathBuf { let output = Command::new("cargo") .args([ "build", "--frozen", "-p", "goose-test", "--bin", "capture", "--message-format=json", ]) .output() .expect("failed to build binary"); if !output.status.success() { panic!("build failed: {}", String::from_utf8_lossy(&output.stderr)); } String::from_utf8_lossy(&output.stdout) .lines() .map(serde_json::from_str::) .filter_map(Result::ok) .filter(|message| message.reason == "compiler-artifact") .filter_map(|message| { if message.target.name == "capture" && message.target.kind.contains(&String::from("bin")) { Some(PathBuf::from(message.executable)) } else { None } }) .next() .expect("failed to parse binary path") } static REPLAY_BINARY_PATH: Lazy = Lazy::new(build_and_get_binary_path); enum TestMode { Record, Playback, } #[test_case( vec!["npx", "-y", "@modelcontextprotocol/server-everything@2026.1.14"], vec![ CallToolRequestParams::new("echo").with_arguments(object!({"message": "Hello, world!" })), CallToolRequestParams::new("get-sum").with_arguments(object!({"a": 1, "b": 2 })), CallToolRequestParams::new("trigger-long-running-operation").with_arguments(object!({"duration": 1, "steps": 5 })), CallToolRequestParams::new("get-structured-content").with_arguments(object!({"location": "New York"})), CallToolRequestParams::new("trigger-sampling-request").with_arguments(object!({"prompt": "Please provide a quote from The Great Gatsby", "maxTokens": 100 })) ], vec![] )] #[test_case( vec!["github-mcp-server", "stdio"], vec![ CallToolRequestParams::new("get_file_contents").with_arguments(object!({ "owner": "block", "repo": "goose", "path": "README.md", "sha": "ab62b863c1666232a67048b6c4e10007a2a5b83c" })), ], vec!["GITHUB_PERSONAL_ACCESS_TOKEN"] )] #[test_case( vec!["uvx", "mcp-server-fetch"], vec![ CallToolRequestParams::new("fetch").with_arguments(object!({ "url": "https://example.com", })) ], vec![] )] #[test_case( vec!["uv", "run", "--with", "fastmcp==2.14.4", "fastmcp", "run", "tests/fastmcp_test_server.py"], vec![ CallToolRequestParams::new("divide").with_arguments(object!({ "dividend": 10, "divisor": 2 })) ], vec![] )] #[tokio::test] async fn test_replayed_session( command: Vec<&str>, tool_calls: Vec, required_envs: Vec<&str>, ) { std::env::set_var("GOOSE_MCP_CLIENT_VERSION", "0.0.0"); // Setup test file for developer extension tests let test_file_path = "/tmp/goose_test/goose.txt"; if let Some(parent) = std::path::Path::new(test_file_path).parent() { fs::create_dir_all(parent).ok(); } fs::write(test_file_path, "# goose\n").ok(); let replay_file_name = command .iter() .map(|s| s.replace("/", "_")) .collect::>() .join(""); let mut replay_file_path = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("should find the project root")); replay_file_path.push("tests"); replay_file_path.push("mcp_replays"); replay_file_path.push(&replay_file_name); let mode = if env::var("GOOSE_RECORD_MCP").is_ok() { TestMode::Record } else { assert!(replay_file_path.exists(), "replay file doesn't exist"); TestMode::Playback }; let mode_arg = match mode { TestMode::Record => "record", TestMode::Playback => "playback", }; let cmd = REPLAY_BINARY_PATH.to_string_lossy().to_string(); let mut args = vec!["stdio", mode_arg] .into_iter() .map(str::to_string) .collect::>(); args.push(replay_file_path.to_string_lossy().to_string()); let mut env = HashMap::new(); if matches!(mode, TestMode::Record) { args.extend(command.into_iter().map(str::to_string)); for key in required_envs { match env::var(key) { Ok(v) => { env.insert(key.to_string(), v); } Err(_) => { eprintln!("skipping due to missing required env variable: {}", key); return; } } } } let envs = Envs::new(env); let extension_config = ExtensionConfig::Stdio { name: "test".to_string(), description: "Test".to_string(), cmd, args, envs, env_keys: vec![], timeout: Some(30), bundled: Some(false), available_tools: vec![], }; let provider = Arc::new(tokio::sync::Mutex::new(Some(Arc::new(MockProvider { model_config: ModelConfig::new("test-model").unwrap(), }) as Arc))); let temp_dir = tempfile::tempdir().unwrap(); let session_manager = Arc::new(goose::session::SessionManager::new( temp_dir.path().to_path_buf(), )); let extension_manager = Arc::new(ExtensionManager::new( provider, session_manager, GoosePlatform::GooseDesktop.to_string(), ExtensionManagerCapabilities { mcpui: true, host_info: None, }, true, )); #[allow(clippy::redundant_closure_call)] let result = (async || -> Result<(), Box> { extension_manager .add_extension(extension_config, None, None, None) .await?; let mut results = Vec::new(); for tool_call in tool_calls { let mut new_call = CallToolRequestParams::new(format!("test__{}", tool_call.name)); if let Some(args) = tool_call.arguments { new_call = new_call.with_arguments(args); } let tool_call = new_call; let ctx = goose::agents::ToolCallContext::new( "test-session-id".to_string(), None, Some("test-id".to_string()), ); let result = extension_manager .dispatch_tool_call(&ctx, tool_call, CancellationToken::default()) .await; let tool_result = result?; results.push(tool_result.result.await?); } let mut results_path = replay_file_path.clone(); results_path.pop(); results_path.push(format!("{}.results.json", &replay_file_name)); match mode { TestMode::Record => { serde_json::to_writer_pretty(File::create(results_path)?, &results)? } TestMode::Playback => assert_eq!( serde_json::from_reader::<_, Vec>(File::open(results_path)?)?, results ), }; Ok(()) })() .await; if let Err(err) = result { if matches!(mode, TestMode::Playback) { let errors = fs::read_to_string(format!("{}.errors.txt", replay_file_path.to_string_lossy())) .expect("could not read errors"); eprintln!("errors from {}", replay_file_path.to_string_lossy()); eprintln!("{}", errors); eprintln!(); } panic!("Test failed: {:?}", err); } }