ch02-01-hello-mcp
Every journey starts with a first step. In this exercise, you'll create your first MCP server - one that responds to a simple "greet" tool.
This might seem simple, but you're learning the foundation that every production MCP server builds upon. By the end, you'll understand:
- How MCP servers are structured
- How tools receive and process input
- How to return results to clients
🎯 Learning Objectives
💬 Discussion
- What do you think an MCP server does? How is it different from a REST API?
- Why might we want to define input types (schemas) for our tools?
- When Claude or another AI calls a tool, what information does it need?
💡 Hints
Hint 1: Start with the builder
Start with the server builder:
#![allow(unused)] fn main() { let server = Server::builder() .name("hello-mcp") .version("1.0.0") // ...continue building }
Hint 2: Configure capabilities
You need to configure capabilities and add a tool:
#![allow(unused)] fn main() { .capabilities(ServerCapabilities { tools: Some(ToolCapabilities::default()), ..Default::default() }) .tool("greet", TypedTool::new(...)) }
Hint 3: Complete structure
The complete structure looks like:
#![allow(unused)] fn main() { let server = Server::builder() .name("hello-mcp") .version("1.0.0") .capabilities(ServerCapabilities { tools: Some(ToolCapabilities::default()), ..Default::default() }) .tool("greet", TypedTool::new("greet", |input: GreetInput| { Box::pin(async move { // Your greeting logic here let greeting = if input.formal.unwrap_or(false) { format!("Good day, {}.", input.name) } else { format!("Hello, {}!", input.name) }; Ok(serde_json::json!({ "message": greeting })) }) })) .build()?; }
⚠️ Try the exercise first! Show Solution
use pmcp::{Server, ServerCapabilities, ToolCapabilities}; use pmcp::server::TypedTool; use serde::Deserialize; use schemars::JsonSchema; use anyhow::Result;#[derive(Deserialize, JsonSchema)] struct GreetInput { /// The name of the person to greet name: String, /// Whether to use a formal greeting style formal: Option<bool>, }
#[tokio::main] async fn main() -> Result<()> { let server = Server::builder() .name("hello-mcp") .version("1.0.0") .capabilities(ServerCapabilities { tools: Some(ToolCapabilities::default()), ..Default::default() }) .tool("greet", TypedTool::new("greet", |input: GreetInput| { Box::pin(async move { let greeting = if input.formal.unwrap_or(false) { format!("Good day, {}.", input.name) } else { format!("Hello, {}!", input.name) }; Ok(serde_json::json!({ "message": greeting })) }) })) .build()?;
// In a real server, you'd run this with a transport // For now, we just verify it builds println!("Server '{}' v{} ready!", server.name(), server.version()); Ok(())}
Explanation
Let's break down what this code does:
1. Input Definition (GreetInput)
#[derive(Deserialize)]- Allows parsing JSON input from clients#[derive(JsonSchema)]- Generates a schema that tells AI what inputs are validOption<bool>- Makes theformalfield optional
2. Server Builder Pattern
Server::builder()- Starts building a server configuration.name()/.version()- Metadata that identifies your server.capabilities()- Declares what the server can do (tools, resources, etc.).tool()- Registers a tool that clients can call
3. TypedTool
- Wraps your handler function with type information
- Automatically deserializes JSON input to your struct
- The closure receives typed input and returns a JSON result
4. Async Handler
Box::pin(async move { ... })- Creates an async future- Returns
Result<Value>- Either a JSON response or an error
Why This Pattern?
- Type safety catches errors at compile time
- Schemas help AI understand how to call your tools
- The builder pattern makes configuration clear and extensible
🧪 Tests
Run these tests locally with:
cargo test
View Test Code
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*;#[test] fn test_informal_greeting() { let input = GreetInput { name: "Alice".to_string(), formal: None, }; let result = create_greeting(&input); assert!(result.contains("Hello")); assert!(result.contains("Alice")); } #[test] fn test_formal_greeting() { let input = GreetInput { name: "Dr. Smith".to_string(), formal: Some(true), }; let result = create_greeting(&input); assert!(result.contains("Good day")); assert!(result.contains("Dr. Smith")); } #[test] fn test_explicit_informal() { let input = GreetInput { name: "Bob".to_string(), formal: Some(false), }; let result = create_greeting(&input); assert!(result.contains("Hello")); } fn create_greeting(input: &GreetInput) -> String { if input.formal.unwrap_or(false) { format!("Good day, {}.", input.name) } else { format!("Hello, {}!", input.name) } } }}
🤔 Reflection
- Why do we use a struct with derive macros instead of just parsing JSON manually?
- What happens if a client sends an input that doesn't match the schema?
- How might you extend this server to greet in different languages?
- What would change if you wanted to add a second tool to this server?