ch02-02-calculator
Now that you've created your first MCP server, let's build something more useful: a calculator. But this isn't just about math - it's about learning how to handle different operations, validate inputs, and return meaningful errors.
Think about it: when an AI asks your calculator to divide by zero, what should happen? When someone passes "abc" instead of a number, how do you respond helpfully?
Production MCP servers must handle edge cases gracefully. This exercise teaches you how.
🎯 Learning Objectives
💬 Discussion
- If you were an AI trying to use a calculator, what operations would you expect?
- What should happen if someone tries to divide by zero?
- How can error messages help an AI correct its request?
- Should a calculator tool accept 'two plus three' or just '2 + 3'?
💡 Hints
Hint 1: Start with the match
Use pattern matching to handle each operation:
#![allow(unused)] fn main() { fn calculate(input: &CalculateInput) -> Result<CalculateResult> { let (result, op_symbol) = match input.operation { Operation::Add => (input.a + input.b, "+"), // Add other operations... };// Build the result }}
Hint 2: Handle division safely
Check for division by zero before computing:
#![allow(unused)] fn main() { Operation::Divide => { if input.b == 0.0 { return Err(anyhow!("Cannot divide by zero")); } (input.a / input.b, "/") } }
Hint 3: Complete calculate function
#![allow(unused)] fn main() { fn calculate(input: &CalculateInput) -> Result<CalculateResult> { let (result, op_symbol) = match input.operation { Operation::Add => (input.a + input.b, "+"), Operation::Subtract => (input.a - input.b, "-"), Operation::Multiply => (input.a * input.b, "*"), Operation::Divide => { if input.b == 0.0 { return Err(anyhow!("Cannot divide by zero")); } (input.a / input.b, "/") } };if result.is_nan() || result.is_infinite() { return Err(anyhow!("Invalid result")); } Ok(CalculateResult { result, expression: format!("{} {} {} = {}", input.a, op_symbol, input.b, result), }) }}
⚠️ Try the exercise first! Show Solution
#![allow(unused)] fn main() { use pmcp::{Server, ServerCapabilities, ToolCapabilities}; use pmcp::server::TypedTool; use serde::{Deserialize, Serialize}; use schemars::JsonSchema; use anyhow::{Result, anyhow};#[derive(Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] enum Operation { Add, Subtract, Multiply, Divide, }
#[derive(Deserialize, JsonSchema)] struct CalculateInput { a: f64, b: f64, operation: Operation, }
#[derive(Serialize)] struct CalculateResult { result: f64, expression: String, }
fn calculate(input: &CalculateInput) -> Result<CalculateResult> { let (result, op_symbol) = match input.operation { Operation::Add => (input.a + input.b, "+"), Operation::Subtract => (input.a - input.b, "-"), Operation::Multiply => (input.a * input.b, "*"), Operation::Divide => { if input.b == 0.0 { return Err(anyhow!( "Cannot divide by zero. Please provide a non-zero divisor." )); } (input.a / input.b, "/") } };
if result.is_nan() || result.is_infinite() { return Err(anyhow!( "Calculation produced an invalid result (NaN or Infinity)" )); } Ok(CalculateResult { result, expression: format!("{} {} {} = {}", input.a, op_symbol, input.b, result), }) }}
#[tokio::main] async fn main() -> Result<()> { let server = Server::builder() .name("calculator") .version("1.0.0") .capabilities(ServerCapabilities { tools: Some(ToolCapabilities::default()), ..Default::default() }) .tool("calculate", TypedTool::new("calculate", |input: CalculateInput| { Box::pin(async move { match calculate(&input) { Ok(result) => Ok(serde_json::to_value(result)?), Err(e) => Ok(serde_json::json!({ "error": e.to_string(), "suggestion": "Check your inputs and try again" })), } }) })) .build()?;
println!("Calculator server ready!"); Ok(())}
Explanation
This solution demonstrates several important patterns:
1. Enum for Operations Using an enum instead of a string for operations:
- Compile-time validation of operation types
- Pattern matching ensures all cases are handled
#[serde(rename_all = "lowercase")]allows JSON like"add"instead of"Add"
2. Separation of Concerns
The calculate() function is separate from the tool handler:
- Easier to test (pure function, no async)
- Cleaner error handling
- Reusable logic
3. Defensive Error Handling
- Check for division by zero BEFORE computing
- Check for NaN/Infinity AFTER computing
- Return helpful error messages that guide the AI
4. Human-Readable Output
- The
expressionfield shows the full calculation - Helps debugging and transparency
- AI can show this to users
5. Error Response Pattern Instead of returning a tool error (which might retry), we return a structured error response. This lets the AI understand what went wrong and explain it to the user.
🧪 Tests
Run these tests locally with:
cargo test
View Test Code
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*;#[test] fn test_addition() { let input = CalculateInput { a: 5.0, b: 3.0, operation: Operation::Add, }; let result = calculate(&input).unwrap(); assert_eq!(result.result, 8.0); } #[test] fn test_division_by_zero() { let input = CalculateInput { a: 10.0, b: 0.0, operation: Operation::Divide, }; assert!(calculate(&input).is_err()); } #[test] fn test_expression_format() { let input = CalculateInput { a: 10.0, b: 5.0, operation: Operation::Multiply, }; let result = calculate(&input).unwrap(); assert!(result.expression.contains("10 * 5 = 50")); } }}
🤔 Reflection
- Why do we check for division by zero before computing, not after?
- What's the advantage of returning a structured error vs failing the tool call?
- How would you add a 'power' operation to this calculator?
- What might go wrong with floating-point math that integers wouldn't have?