use std::collections::HashMap;
use std::path::{Path, PathBuf};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{ServerCapabilities, ServerInfo};
use rmcp::{tool, tool_handler, tool_router, ServerHandler};
use tempfile::TempDir;
use crate::cli::calculate_core;
use crate::cli::{
audit_core, bayesian_core, bootstrap_core, compare_core, decision_tree_core, examples_core,
export_buffer_core, export_core, functions_core, goal_seek_core, import_core,
real_options_core, scenarios_core, schema_core, sensitivity_core, simulate_core, tornado_core,
validate_core, variance_core,
};
use super::types::{
AuditRequest, BayesianRequest, BootstrapRequest, BreakEvenRequest, CalculateRequest,
CompareRequest, DecisionTreeRequest, ExamplesRequest, ExportRequest, FunctionsRequest,
GoalSeekRequest, ImportRequest, RealOptionsRequest, ScenariosRequest, SchemaRequest,
SensitivityRequest, SimulateRequest, TornadoRequest, ValidateRequest, VarianceRequest,
};
fn resolve_model_input(
file_path: Option<&str>,
content: Option<&str>,
includes: Option<&HashMap<String, String>>,
) -> Result<(PathBuf, Option<TempDir>), String> {
match (file_path, content) {
(Some(_), Some(_)) => Err("Provide either file_path or content, not both".into()),
(None, None) => Err("Either file_path or content is required".into()),
(Some(path), None) => Ok((PathBuf::from(path), None)),
(None, Some(yaml)) => {
let tmp = TempDir::new().map_err(|e| format!("Failed to create temp dir: {e}"))?;
resolve_inline_includes(yaml, includes, tmp.path())?;
std::fs::write(tmp.path().join("model.yaml"), yaml)
.map_err(|e| format!("Failed to write temp model: {e}"))?;
Ok((tmp.path().join("model.yaml"), Some(tmp)))
},
}
}
fn resolve_inline_includes(
yaml: &str,
includes: Option<&HashMap<String, String>>,
dir: &Path,
) -> Result<(), String> {
let parsed: serde_yaml_ng::Value =
serde_yaml_ng::from_str(yaml).map_err(|e| format!("Invalid YAML: {e}"))?;
let includes_seq = match parsed.get("_includes") {
Some(serde_yaml_ng::Value::Sequence(seq)) => seq,
Some(_) => return Err("_includes must be a sequence".into()),
None => return Ok(()), };
let map = includes.ok_or(
"Model has _includes but no 'includes' map was provided. \
Pass inline content for each included namespace.",
)?;
for entry in includes_seq {
let file = entry
.get("file")
.and_then(serde_yaml_ng::Value::as_str)
.ok_or("Each _includes entry must have a 'file' field")?;
let namespace = entry
.get("as")
.and_then(serde_yaml_ng::Value::as_str)
.ok_or_else(|| format!("Include '{file}' must have an 'as' field"))?;
let include_content = map.get(namespace).ok_or_else(|| {
format!(
"No content provided for include namespace '{namespace}'. \
Add it to the 'includes' map."
)
})?;
let dest = dir.join(file);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create dir for include '{file}': {e}"))?;
}
std::fs::write(&dest, include_content)
.map_err(|e| format!("Failed to write include '{file}': {e}"))?;
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct ForgeMcpServer {
tool_router: ToolRouter<Self>,
}
impl ForgeMcpServer {
#[must_use]
pub fn new() -> Self {
Self {
tool_router: Self::tool_router(),
}
}
}
impl Default for ForgeMcpServer {
fn default() -> Self {
Self::new()
}
}
fn to_json<T: serde::Serialize>(result: &T) -> String {
serde_json::to_string(result).unwrap_or_default()
}
#[tool_router]
#[allow(clippy::unused_self)] impl ForgeMcpServer {
#[tool(
name = "forge_validate",
description = "Validate a Forge YAML model file for formula errors, circular dependencies, and type mismatches."
)]
fn validate(&self, Parameters(req): Parameters<ValidateRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
validate_core(&path)
.map(|r| to_json(&r))
.map_err(|e| format!("Validation failed: {e}"))
}
#[tool(
name = "forge_calculate",
description = "Calculate all formulas in a Forge YAML model and optionally update the file."
)]
fn calculate(&self, Parameters(req): Parameters<CalculateRequest>) -> Result<String, String> {
let is_inline = req.content.is_some();
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
let result = calculate_core(path.as_path(), req.dry_run, req.scenario.as_deref())
.map_err(|e| format!("Calculation failed: {e}"))?;
let mut json_val: serde_json::Value =
serde_json::to_value(&result).map_err(|e| format!("Serialization failed: {e}"))?;
if is_inline && !req.dry_run {
if let Ok(calculated) = std::fs::read_to_string(&path) {
json_val["calculated_content"] = serde_json::Value::String(calculated);
}
}
serde_json::to_string(&json_val).map_err(|e| format!("Serialization failed: {e}"))
}
#[tool(
name = "forge_audit",
description = "Audit a specific variable to see its dependency tree and calculated value."
)]
fn audit(&self, Parameters(req): Parameters<AuditRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
audit_core(&path, &req.variable)
.map(|r| to_json(&r))
.map_err(|e| format!("Audit failed: {e}"))
}
#[tool(
name = "forge_export",
description = "Export a Forge YAML model to an Excel workbook. \
If `excel_path` is provided, writes the .xlsx file to disk. \
If omitted, returns the workbook inline as base64 in the \
`excel_base64` response field (for sandboxed clients)."
)]
fn export(&self, Parameters(req): Parameters<ExportRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.yaml_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
req.excel_path.as_ref().map_or_else(
|| {
export_buffer_core(&path)
.map(|r| to_json(&r))
.map_err(|e| format!("Export failed: {e}"))
},
|excel_path| {
export_core(&path, Path::new(excel_path))
.map(|r| to_json(&r))
.map_err(|e| format!("Export failed: {e}"))
},
)
}
#[tool(
name = "forge_import",
description = "Import an Excel workbook into a Forge YAML model."
)]
fn import(&self, Parameters(req): Parameters<ImportRequest>) -> Result<String, String> {
import_core(
Path::new(&req.excel_path),
Path::new(&req.yaml_path),
false,
false,
)
.map(|r| to_json(&r))
.map_err(|e| format!("Import failed: {e}"))
}
#[tool(
name = "forge_sensitivity",
description = "Run sensitivity analysis by varying one or two input variables and observing output changes. Essential for what-if modeling and risk assessment."
)]
fn sensitivity(
&self,
Parameters(req): Parameters<SensitivityRequest>,
) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
sensitivity_core(
&path,
&req.vary,
&req.range,
req.vary2.as_deref(),
req.range2.as_deref(),
&req.output,
)
.map(|r| to_json(&r))
.map_err(|e| format!("Sensitivity analysis failed: {e}"))
}
#[tool(
name = "forge_goal_seek",
description = "Find the input value needed to achieve a target output. Uses bisection solver. Example: 'What price do I need for $100K profit?'"
)]
fn goal_seek(&self, Parameters(req): Parameters<GoalSeekRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
goal_seek_core(
&path,
&req.target,
req.value,
&req.vary,
(req.min, req.max),
req.tolerance,
)
.map(|r| to_json(&r))
.map_err(|e| format!("Goal seek failed: {e}"))
}
#[tool(
name = "forge_break_even",
description = "Find the break-even point where an output equals zero. Example: 'At what units does profit = 0?'"
)]
fn break_even(&self, Parameters(req): Parameters<BreakEvenRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
goal_seek_core(
&path,
&req.output,
0.0,
&req.vary,
(req.min, req.max),
0.0001,
)
.map(|r| to_json(&r))
.map_err(|e| format!("Break-even analysis failed: {e}"))
}
#[tool(
name = "forge_variance",
description = "Compare budget vs actual with variance analysis. Shows absolute and percentage variances with favorable/unfavorable indicators."
)]
fn variance(&self, Parameters(req): Parameters<VarianceRequest>) -> Result<String, String> {
let (budget_path, _tmpdir_b) = resolve_model_input(
req.budget_path.as_deref(),
req.budget_content.as_deref(),
req.includes.as_ref(),
)?;
let (actual_path, _tmpdir_a) = resolve_model_input(
req.actual_path.as_deref(),
req.actual_content.as_deref(),
req.includes.as_ref(),
)?;
let threshold = req.threshold.unwrap_or(10.0);
variance_core(&budget_path, &actual_path, threshold)
.map(|r| to_json(&r))
.map_err(|e| format!("Variance analysis failed: {e}"))
}
#[tool(
name = "forge_compare",
description = "Compare calculation results across multiple scenarios side-by-side. Useful for what-if analysis."
)]
fn compare(&self, Parameters(req): Parameters<CompareRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
compare_core(&path, &req.scenarios)
.map(|r| to_json(&r))
.map_err(|e| format!("Scenario comparison failed: {e}"))
}
#[tool(
name = "forge_simulate",
description = "Run Monte Carlo simulation with probabilistic distributions (MC.Normal, MC.Triangular, MC.Uniform, MC.PERT, MC.Lognormal). Returns statistics, percentiles, and threshold probabilities."
)]
fn simulate(&self, Parameters(req): Parameters<SimulateRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
#[allow(clippy::cast_possible_truncation)]
let iterations = req.iterations.map(|n| n as usize);
simulate_core(&path, iterations, req.seed, req.sampling.as_deref())
.map_err(|e| format!("Simulation failed: {e}"))
.and_then(|r| {
r.to_json()
.map_err(|e| format!("Serialization failed: {e}"))
})
}
#[tool(
name = "forge_scenarios",
description = "Run probability-weighted scenario analysis (Base/Bull/Bear). Each scenario overrides scalar values and calculates results."
)]
fn scenarios(&self, Parameters(req): Parameters<ScenariosRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
scenarios_core(&path, req.scenario_filter.as_deref())
.map(|r| to_json(&r))
.map_err(|e| format!("Scenario analysis failed: {e}"))
}
#[tool(
name = "forge_decision_tree",
description = "Analyze decision trees using backward induction. Returns optimal path, expected value, decision policy, and risk profile."
)]
fn decision_tree(
&self,
Parameters(req): Parameters<DecisionTreeRequest>,
) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
decision_tree_core(&path)
.map(|r| to_json(&r))
.map_err(|e| format!("Decision tree analysis failed: {e}"))
}
#[tool(
name = "forge_real_options",
description = "Value managerial flexibility (defer/expand/abandon) using real options pricing. Returns option values, exercise probabilities, and project value with options."
)]
fn real_options(
&self,
Parameters(req): Parameters<RealOptionsRequest>,
) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
real_options_core(&path)
.map(|r| to_json(&r))
.map_err(|e| format!("Real options analysis failed: {e}"))
}
#[tool(
name = "forge_tornado",
description = "Generate tornado sensitivity diagram. Varies each input one-at-a-time to show which inputs have the greatest impact on the output."
)]
fn tornado(&self, Parameters(req): Parameters<TornadoRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
tornado_core(&path, req.output_var.as_deref())
.map(|r| to_json(&r))
.map_err(|e| format!("Tornado analysis failed: {e}"))
}
#[tool(
name = "forge_bootstrap",
description = "Non-parametric bootstrap resampling for confidence intervals. Returns original estimate, bootstrap mean, std error, bias, and confidence intervals."
)]
fn bootstrap(&self, Parameters(req): Parameters<BootstrapRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
#[allow(clippy::cast_possible_truncation)]
let iterations = req.iterations.map(|n| n as usize);
bootstrap_core(&path, iterations, req.seed, req.confidence_levels)
.map(|r| to_json(&r))
.map_err(|e| format!("Bootstrap analysis failed: {e}"))
}
#[tool(
name = "forge_bayesian",
description = "Bayesian network inference. Query posterior probabilities with optional evidence. Returns probability distributions for each variable state."
)]
fn bayesian(&self, Parameters(req): Parameters<BayesianRequest>) -> Result<String, String> {
let (path, _tmpdir) = resolve_model_input(
req.file_path.as_deref(),
req.content.as_deref(),
req.includes.as_ref(),
)?;
let evidence = req.evidence.unwrap_or_default();
bayesian_core(&path, req.query_var.as_deref(), &evidence)
.map(|r| to_json(&r))
.map_err(|e| format!("Bayesian inference failed: {e}"))
}
#[tool(
name = "forge_schema",
description = "Get JSON Schema for Forge YAML model formats. Use to understand the structure of valid Forge model files."
)]
fn schema(&self, Parameters(req): Parameters<SchemaRequest>) -> Result<String, String> {
schema_core(req.version.as_deref()).map_err(|e| format!("Schema error: {e}"))
}
#[tool(
name = "forge_functions",
description = "List all 173 supported Excel-compatible functions with descriptions and syntax. Organized by category (Financial, Statistical, Math, Lookup, etc.)."
)]
fn functions(&self, Parameters(_req): Parameters<FunctionsRequest>) -> Result<String, String> {
functions_core()
.map(|r| to_json(&r))
.map_err(|e| format!("Functions list failed: {e}"))
}
#[tool(
name = "forge_examples",
description = "Get runnable YAML examples for all Forge capabilities. Use without a name to list available examples, or specify a name to get the full YAML content."
)]
fn examples(&self, Parameters(req): Parameters<ExamplesRequest>) -> Result<String, String> {
examples_core(req.name.as_deref())
.map(|r| to_json(&r))
.map_err(|e| format!("Examples error: {e}"))
}
}
#[tool_handler]
impl ServerHandler for ForgeMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_instructions("Forge MCP Server - 20 tools for AI-native financial modeling. Core: validate, calculate, audit, export, import. Analysis: sensitivity, goal-seek, break-even, variance, compare. Engines: simulate (Monte Carlo), scenarios, decision-tree, real-options, tornado, bootstrap, bayesian. Discovery: schema, functions, examples. 173 Excel-compatible functions. All tools return structured JSON.")
.with_server_info(
rmcp::model::Implementation::new("forge", env!("CARGO_PKG_VERSION"))
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use rmcp::handler::server::wrapper::Parameters;
use tempfile::TempDir;
fn ok_text(result: Result<String, String>) -> String {
result.expect("expected Ok result")
}
fn err_text(result: Result<String, String>) -> String {
result.expect_err("expected Err result")
}
const INLINE_YAML: &str = r#"
_forge_version: "5.0.0"
scalars:
revenue:
value: 100000
costs:
value: 60000
profit:
value: 0
formula: "=revenue - costs"
"#;
#[test]
fn test_server_get_info() {
let server = ForgeMcpServer::new();
let info = server.get_info();
assert_eq!(info.server_info.name, "forge");
assert!(info.instructions.is_some());
assert!(info.capabilities.tools.is_some());
}
#[test]
fn test_tool_count() {
let server = ForgeMcpServer::new();
let tools = server.tool_router.list_all();
assert_eq!(tools.len(), 20, "Expected 20 tools, got {}", tools.len());
}
#[test]
fn test_tool_names() {
let server = ForgeMcpServer::new();
let tools = server.tool_router.list_all();
let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect();
assert!(names.contains(&"forge_validate"));
assert!(names.contains(&"forge_calculate"));
assert!(names.contains(&"forge_audit"));
assert!(names.contains(&"forge_export"));
assert!(names.contains(&"forge_import"));
assert!(names.contains(&"forge_sensitivity"));
assert!(names.contains(&"forge_goal_seek"));
assert!(names.contains(&"forge_break_even"));
assert!(names.contains(&"forge_variance"));
assert!(names.contains(&"forge_compare"));
assert!(names.contains(&"forge_simulate"));
assert!(names.contains(&"forge_scenarios"));
assert!(names.contains(&"forge_decision_tree"));
assert!(names.contains(&"forge_real_options"));
assert!(names.contains(&"forge_tornado"));
assert!(names.contains(&"forge_bootstrap"));
assert!(names.contains(&"forge_bayesian"));
assert!(names.contains(&"forge_schema"));
assert!(names.contains(&"forge_functions"));
assert!(names.contains(&"forge_examples"));
}
#[test]
fn test_tool_schemas_are_objects() {
let server = ForgeMcpServer::new();
let tools = server.tool_router.list_all();
for tool in &tools {
assert_eq!(
tool.input_schema.get("type").and_then(|v| v.as_str()),
Some("object"),
"Tool {} schema missing type: object",
tool.name
);
}
}
#[test]
fn test_call_validate_success() {
let server = ForgeMcpServer::new();
let result = server.validate(Parameters(ValidateRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
verbose: false,
}));
assert!(result.is_ok());
}
#[test]
fn test_call_validate_nonexistent() {
let server = ForgeMcpServer::new();
let result = server.validate(Parameters(ValidateRequest {
file_path: Some("nonexistent.yaml".into()),
content: None,
includes: None,
verbose: false,
}));
assert!(result.is_err());
}
#[test]
fn test_call_calculate_dry_run() {
let server = ForgeMcpServer::new();
let result = server.calculate(Parameters(CalculateRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
dry_run: true,
scenario: None,
}));
assert!(result.is_ok());
}
#[test]
fn test_call_calculate_nonexistent() {
let server = ForgeMcpServer::new();
let result = server.calculate(Parameters(CalculateRequest {
file_path: Some("nonexistent.yaml".into()),
content: None,
includes: None,
dry_run: true,
scenario: None,
}));
assert!(result.is_err());
}
#[test]
fn test_call_audit() {
let server = ForgeMcpServer::new();
let _ = server.audit(Parameters(AuditRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
variable: "assumptions.profit".into(),
}));
}
#[test]
fn test_call_export() {
let temp_dir = TempDir::new().unwrap();
let output = temp_dir.path().join("mcp_test_export.xlsx");
let server = ForgeMcpServer::new();
let result = server.export(Parameters(ExportRequest {
yaml_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
excel_path: Some(output.to_str().unwrap().into()),
}));
assert!(result.is_ok());
}
#[test]
fn test_call_import() {
let temp_dir = TempDir::new().unwrap();
let excel_path = temp_dir.path().join("import_test.xlsx");
let yaml_path = temp_dir.path().join("imported.yaml");
let server = ForgeMcpServer::new();
let _ = server.export(Parameters(ExportRequest {
yaml_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
excel_path: Some(excel_path.to_str().unwrap().into()),
}));
let result = server.import(Parameters(ImportRequest {
excel_path: excel_path.to_str().unwrap().into(),
yaml_path: yaml_path.to_str().unwrap().into(),
}));
assert!(result.is_ok());
}
#[test]
fn test_call_sensitivity() {
let server = ForgeMcpServer::new();
let _ = server.sensitivity(Parameters(SensitivityRequest {
file_path: Some("test-data/sensitivity_test.yaml".into()),
content: None,
includes: None,
vary: "price".into(),
range: "80,120,10".into(),
output: "profit".into(),
vary2: None,
range2: None,
}));
}
#[test]
fn test_call_sensitivity_two_var() {
let server = ForgeMcpServer::new();
let _ = server.sensitivity(Parameters(SensitivityRequest {
file_path: Some("test-data/sensitivity_test.yaml".into()),
content: None,
includes: None,
vary: "price".into(),
range: "80,120,10".into(),
output: "profit".into(),
vary2: Some("quantity".into()),
range2: Some("100,200,50".into()),
}));
}
#[test]
fn test_call_goal_seek() {
let server = ForgeMcpServer::new();
let _ = server.goal_seek(Parameters(GoalSeekRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
target: "assumptions.profit".into(),
value: 0.0,
vary: "assumptions.revenue".into(),
min: Some(50_000.0),
max: Some(200_000.0),
tolerance: 0.01,
}));
}
#[test]
fn test_call_break_even() {
let server = ForgeMcpServer::new();
let _ = server.break_even(Parameters(BreakEvenRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
output: "assumptions.profit".into(),
vary: "assumptions.revenue".into(),
min: Some(50_000.0),
max: Some(200_000.0),
}));
}
#[test]
fn test_call_variance() {
let server = ForgeMcpServer::new();
let result = server.variance(Parameters(VarianceRequest {
budget_path: Some("test-data/budget.yaml".into()),
budget_content: None,
actual_path: Some("test-data/budget.yaml".into()),
actual_content: None,
includes: None,
threshold: Some(10.0),
}));
assert!(result.is_ok());
}
#[test]
fn test_call_compare() {
let server = ForgeMcpServer::new();
let result = server.compare(Parameters(CompareRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
scenarios: vec!["base".into(), "optimistic".into()],
}));
assert!(result.is_err());
}
#[test]
fn test_call_compare_empty_scenarios() {
let server = ForgeMcpServer::new();
let _ = server.compare(Parameters(CompareRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
scenarios: vec![],
}));
}
#[test]
fn test_call_simulate() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("mc.yaml");
std::fs::write(
&file,
r#"
_forge_version: "5.0.0"
monte_carlo:
enabled: true
iterations: 100
seed: 42
outputs:
- variable: revenue
percentiles: [50]
scalars:
revenue:
value: 100000
formula: "=MC.Normal(100000, 15000)"
"#,
)
.unwrap();
let server = ForgeMcpServer::new();
let text = ok_text(server.simulate(Parameters(SimulateRequest {
file_path: Some(file.to_str().unwrap().into()),
content: None,
includes: None,
iterations: None,
seed: None,
sampling: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(
parsed["monte_carlo_results"]["iterations"]
.as_u64()
.unwrap()
> 0
);
}
#[test]
fn test_call_simulate_nonexistent() {
let server = ForgeMcpServer::new();
let result = server.simulate(Parameters(SimulateRequest {
file_path: Some("nonexistent.yaml".into()),
content: None,
includes: None,
iterations: None,
seed: None,
sampling: None,
}));
assert!(result.is_err());
}
#[test]
fn test_call_scenarios_dispatch() {
let server = ForgeMcpServer::new();
let e = err_text(server.scenarios(Parameters(ScenariosRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
scenario_filter: None,
})));
assert!(e.contains("scenarios"));
}
#[test]
fn test_call_scenarios_nonexistent() {
let server = ForgeMcpServer::new();
assert!(server
.scenarios(Parameters(ScenariosRequest {
file_path: Some("nonexistent.yaml".into()),
content: None,
includes: None,
scenario_filter: None,
}))
.is_err());
}
#[test]
fn test_call_scenarios_structured_format() {
let content = r#"
_forge_version: "5.0.0"
price:
value: 100
formula: null
units:
value: 50
formula: null
revenue:
value: null
formula: "=price * units"
scenarios:
high:
probability: 0.5
description: "High price scenario"
scalars:
price: 200
low:
probability: 0.5
description: "Low price scenario"
scalars:
price: 50
"#;
let server = ForgeMcpServer::new();
let text = ok_text(server.scenarios(Parameters(ScenariosRequest {
file_path: None,
content: Some(content.to_string()),
includes: None,
scenario_filter: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed["scenarios"].as_array().is_some());
let scenarios = parsed["scenarios"].as_array().unwrap();
assert_eq!(scenarios.len(), 2);
let ev = parsed["expected_values"]["revenue"].as_f64().unwrap();
assert!((ev - 6250.0).abs() < 1.0, "Expected ~6250, got {ev}");
}
#[test]
fn test_call_compare_structured_format() {
let content = r#"
_forge_version: "5.0.0"
price:
value: 100
formula: null
units:
value: 50
formula: null
revenue:
value: null
formula: "=price * units"
scenarios:
high:
probability: 0.5
description: "High price"
scalars:
price: 200
low:
probability: 0.5
description: "Low price"
scalars:
price: 50
"#;
let server = ForgeMcpServer::new();
let text = ok_text(server.compare(Parameters(CompareRequest {
file_path: None,
content: Some(content.to_string()),
includes: None,
scenarios: vec!["high".to_string(), "low".to_string()],
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed["scenarios"].as_array().is_some());
let scenarios = parsed["scenarios"].as_array().unwrap();
assert_eq!(scenarios.len(), 2);
}
#[test]
fn test_call_scenarios_grouped_scalar_override() {
let content = r#"
_forge_version: "5.0.0"
assumptions:
growth_rate:
value: 0.10
formula: null
revenue:
value: 1000000
formula: null
profit:
value: null
formula: "=assumptions.revenue * assumptions.growth_rate"
scenarios:
bull:
probability: 0.5
scalars:
growth_rate: 0.25
bear:
probability: 0.5
scalars:
growth_rate: 0.02
"#;
let server = ForgeMcpServer::new();
let text = ok_text(server.scenarios(Parameters(ScenariosRequest {
file_path: None,
content: Some(content.to_string()),
includes: None,
scenario_filter: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
let scenarios = parsed["scenarios"].as_array().unwrap();
for s in scenarios {
let name = s["name"].as_str().unwrap();
let profit = s["scalars"]["profit"].as_f64().unwrap();
match name {
"bull" => assert!(
(profit - 250_000.0).abs() < 1.0,
"bull profit: expected 250000, got {profit}"
),
"bear" => assert!(
(profit - 20_000.0).abs() < 1.0,
"bear profit: expected 20000, got {profit}"
),
_ => panic!("unexpected scenario: {name}"),
}
}
}
#[test]
fn test_call_compare_grouped_scalar_override() {
let content = r#"
_forge_version: "5.0.0"
assumptions:
growth_rate:
value: 0.10
formula: null
revenue:
value: 1000000
formula: null
profit:
value: null
formula: "=assumptions.revenue * assumptions.growth_rate"
scenarios:
bull:
growth_rate: 0.25
bear:
growth_rate: 0.02
"#;
let server = ForgeMcpServer::new();
let text = ok_text(server.compare(Parameters(CompareRequest {
file_path: None,
content: Some(content.to_string()),
includes: None,
scenarios: vec!["bull".to_string(), "bear".to_string()],
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
let values = &parsed["values"];
let bull_profit = values["profit"]["bull"].as_f64().unwrap();
let bear_profit = values["profit"]["bear"].as_f64().unwrap();
assert!(
(bull_profit - 250_000.0).abs() < 1.0,
"bull profit: expected 250000, got {bull_profit}"
);
assert!(
(bear_profit - 20_000.0).abs() < 1.0,
"bear profit: expected 20000, got {bear_profit}"
);
}
#[test]
fn test_call_decision_tree() {
let server = ForgeMcpServer::new();
let text = ok_text(server.decision_tree(Parameters(DecisionTreeRequest {
file_path: Some("examples/decision-tree.yaml".into()),
content: None,
includes: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed["optimal_path"].as_array().is_some());
}
#[test]
fn test_call_real_options() {
let server = ForgeMcpServer::new();
let text = ok_text(server.real_options(Parameters(RealOptionsRequest {
file_path: Some("examples/real-options.yaml".into()),
content: None,
includes: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed["total_option_value"].as_f64().is_some());
}
#[test]
fn test_call_tornado() {
let server = ForgeMcpServer::new();
let text = ok_text(server.tornado(Parameters(TornadoRequest {
file_path: Some("examples/tornado.yaml".into()),
content: None,
includes: None,
output_var: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed["base_value"].as_f64().is_some());
}
#[test]
fn test_call_bootstrap() {
let server = ForgeMcpServer::new();
let text = ok_text(server.bootstrap(Parameters(BootstrapRequest {
file_path: Some("examples/bootstrap.yaml".into()),
content: None,
includes: None,
iterations: None,
seed: Some(42),
confidence_levels: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed["original_estimate"].as_f64().is_some());
}
#[test]
fn test_call_bayesian_dispatch() {
let server = ForgeMcpServer::new();
let e = err_text(server.bayesian(Parameters(BayesianRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
query_var: None,
evidence: None,
})));
assert!(e.contains("bayesian_network"));
}
#[test]
fn test_call_bayesian_with_evidence() {
let server = ForgeMcpServer::new();
let _ = server.bayesian(Parameters(BayesianRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
query_var: None,
evidence: Some(vec!["economy=growth".into()]),
}));
}
#[test]
fn test_call_schema_v5() {
let server = ForgeMcpServer::new();
let text = ok_text(server.schema(Parameters(SchemaRequest {
version: Some("v5".into()),
})));
assert!(text.contains("$schema"));
}
#[test]
fn test_call_schema_v1() {
let server = ForgeMcpServer::new();
assert!(server
.schema(Parameters(SchemaRequest {
version: Some("v1".into()),
}))
.is_ok());
}
#[test]
fn test_call_schema_list() {
let server = ForgeMcpServer::new();
let text = ok_text(server.schema(Parameters(SchemaRequest { version: None })));
assert!(text.contains("available_versions"));
}
#[test]
fn test_call_schema_invalid() {
let server = ForgeMcpServer::new();
assert!(server
.schema(Parameters(SchemaRequest {
version: Some("v99".into()),
}))
.is_err());
}
#[test]
fn test_call_functions() {
let server = ForgeMcpServer::new();
let text = ok_text(server.functions(Parameters(FunctionsRequest {})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["edition"], "enterprise");
assert!(parsed["total"].as_u64().unwrap() >= 170);
}
#[test]
fn test_call_examples_list() {
let server = ForgeMcpServer::new();
let text = ok_text(server.examples(Parameters(ExamplesRequest { name: None })));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed.as_array().is_some_and(|a| a.len() >= 9));
}
#[test]
fn test_call_examples_specific() {
let server = ForgeMcpServer::new();
let text = ok_text(server.examples(Parameters(ExamplesRequest {
name: Some("monte-carlo".into()),
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(parsed["name"], "monte-carlo");
assert!(parsed["content"]
.as_str()
.is_some_and(|c| c.contains("monte_carlo")));
}
#[test]
fn test_call_examples_unknown() {
let server = ForgeMcpServer::new();
assert!(server
.examples(Parameters(ExamplesRequest {
name: Some("nonexistent".into()),
}))
.is_err());
}
#[test]
fn test_validate_returns_structured_json() {
let server = ForgeMcpServer::new();
let text = ok_text(server.validate(Parameters(ValidateRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
verbose: false,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed.get("tables_valid").is_some());
assert!(parsed.get("scalars_valid").is_some());
assert!(parsed.get("table_count").is_some());
}
#[test]
fn test_calculate_returns_structured_json() {
let server = ForgeMcpServer::new();
let text = ok_text(server.calculate(Parameters(CalculateRequest {
file_path: Some("test-data/budget.yaml".into()),
content: None,
includes: None,
dry_run: true,
scenario: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed.get("tables").is_some());
assert!(parsed.get("scalars").is_some());
assert_eq!(parsed["dry_run"], true);
}
#[test]
fn test_resolve_model_input_file_path() {
let (path, tmpdir) =
resolve_model_input(Some("test-data/budget.yaml"), None, None).unwrap();
assert_eq!(path, PathBuf::from("test-data/budget.yaml"));
assert!(tmpdir.is_none());
}
#[test]
fn test_resolve_model_input_content() {
let (path, tmpdir) = resolve_model_input(None, Some(INLINE_YAML), None).unwrap();
assert!(tmpdir.is_some());
assert!(path.exists());
assert!(path.ends_with("model.yaml"));
}
#[test]
fn test_resolve_model_input_both_error() {
let result = resolve_model_input(Some("file.yaml"), Some("content"), None);
assert_eq!(
result.unwrap_err(),
"Provide either file_path or content, not both"
);
}
#[test]
fn test_resolve_model_input_neither_error() {
let result = resolve_model_input(None, None, None);
assert_eq!(
result.unwrap_err(),
"Either file_path or content is required"
);
}
#[test]
fn test_inline_validate() {
let server = ForgeMcpServer::new();
let text = ok_text(server.validate(Parameters(ValidateRequest {
file_path: None,
content: Some(INLINE_YAML.into()),
includes: None,
verbose: false,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed.get("scalars_valid").is_some());
}
#[test]
fn test_inline_calculate_dry_run() {
let server = ForgeMcpServer::new();
let text = ok_text(server.calculate(Parameters(CalculateRequest {
file_path: None,
content: Some(INLINE_YAML.into()),
includes: None,
dry_run: true,
scenario: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed.get("scalars").is_some());
assert_eq!(parsed["dry_run"], true);
assert!(parsed.get("calculated_content").is_none());
}
#[test]
fn test_inline_calculate_write_back() {
let server = ForgeMcpServer::new();
let text = ok_text(server.calculate(Parameters(CalculateRequest {
file_path: None,
content: Some(INLINE_YAML.into()),
includes: None,
dry_run: false,
scenario: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed.get("calculated_content").is_some());
let calculated = parsed["calculated_content"].as_str().unwrap();
assert!(calculated.contains("profit"));
}
#[test]
fn test_inline_with_includes() {
let yaml_with_includes = r#"
_forge_version: "5.0.0"
_includes:
- file: "pricing.yaml"
as: "pricing"
scalars:
total:
value: 100
"#;
let pricing_yaml = r#"
_forge_version: "5.0.0"
scalars:
price:
value: 50
"#;
let mut includes = HashMap::new();
includes.insert("pricing".into(), pricing_yaml.into());
let server = ForgeMcpServer::new();
let text = ok_text(server.validate(Parameters(ValidateRequest {
file_path: None,
content: Some(yaml_with_includes.into()),
includes: Some(includes),
verbose: false,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed.get("scalars_valid").is_some());
}
#[test]
fn test_inline_includes_missing_namespace() {
let yaml_with_includes = r#"
_forge_version: "5.0.0"
_includes:
- file: "pricing.yaml"
as: "pricing"
scalars:
total:
value: 100
"#;
let mut includes = HashMap::new();
includes.insert("wrong_namespace".into(), "content".into());
let server = ForgeMcpServer::new();
let result = server.validate(Parameters(ValidateRequest {
file_path: None,
content: Some(yaml_with_includes.into()),
includes: Some(includes),
verbose: false,
}));
let err = err_text(result);
assert!(err.contains("pricing"));
}
#[test]
fn test_inline_includes_no_map() {
let yaml_with_includes = r#"
_forge_version: "5.0.0"
_includes:
- file: "pricing.yaml"
as: "pricing"
scalars:
total:
value: 100
"#;
let server = ForgeMcpServer::new();
let result = server.validate(Parameters(ValidateRequest {
file_path: None,
content: Some(yaml_with_includes.into()),
includes: None,
verbose: false,
}));
let err = err_text(result);
assert!(err.contains("includes"));
}
#[test]
fn test_inline_includes_nested_dir() {
let yaml_with_includes = r#"
_forge_version: "5.0.0"
_includes:
- file: "data/sub/pricing.yaml"
as: "pricing"
scalars:
total:
value: 100
"#;
let pricing_yaml = r#"
_forge_version: "5.0.0"
scalars:
price:
value: 25
"#;
let mut includes = HashMap::new();
includes.insert("pricing".into(), pricing_yaml.into());
let server = ForgeMcpServer::new();
let text = ok_text(server.validate(Parameters(ValidateRequest {
file_path: None,
content: Some(yaml_with_includes.into()),
includes: Some(includes),
verbose: false,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed.get("scalars_valid").is_some());
}
#[test]
fn test_inline_variance() {
let budget = r#"
_forge_version: "5.0.0"
scalars:
revenue:
value: 100000
costs:
value: 60000
"#;
let actual = r#"
_forge_version: "5.0.0"
scalars:
revenue:
value: 95000
costs:
value: 65000
"#;
let server = ForgeMcpServer::new();
let result = server.variance(Parameters(VarianceRequest {
budget_path: None,
budget_content: Some(budget.into()),
actual_path: None,
actual_content: Some(actual.into()),
includes: None,
threshold: Some(10.0),
}));
assert!(result.is_ok());
}
#[test]
fn test_inline_export() {
let temp_dir = TempDir::new().unwrap();
let output = temp_dir.path().join("inline_export.xlsx");
let server = ForgeMcpServer::new();
let result = server.export(Parameters(ExportRequest {
yaml_path: None,
content: Some(INLINE_YAML.into()),
includes: None,
excel_path: Some(output.to_str().unwrap().into()),
}));
assert!(result.is_ok());
}
#[test]
fn test_inline_export_buffer() {
let server = ForgeMcpServer::new();
let text = ok_text(server.export(Parameters(ExportRequest {
yaml_path: None,
content: Some(INLINE_YAML.into()),
includes: None,
excel_path: None,
})));
let parsed: serde_json::Value = serde_json::from_str(&text).unwrap();
assert!(parsed["excel_base64"].as_str().is_some());
assert!(parsed["byte_count"].as_u64().unwrap() > 0);
let b64 = parsed["excel_base64"].as_str().unwrap();
let bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).unwrap();
assert_eq!(
&bytes[..4],
b"PK\x03\x04",
"Should be a valid ZIP/XLSX header"
);
}
}