Exercise: Your First MCP Server

ch02-01-hello-mcp
⭐ beginner ⏱️ 20 min

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

Thinking

Doing

💬 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?
src/main.rs

💡 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&#x27;d run this with a transport
// For now, we just verify it builds
println!(&quot;Server &#x27;{}&#x27; v{} ready!&quot;, 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 valid
  • Option<bool> - Makes the formal field 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: &quot;Alice&quot;.to_string(),
        formal: None,
    };
    let result = create_greeting(&amp;input);
    assert!(result.contains(&quot;Hello&quot;));
    assert!(result.contains(&quot;Alice&quot;));
}

#[test]
fn test_formal_greeting() {
    let input = GreetInput {
        name: &quot;Dr. Smith&quot;.to_string(),
        formal: Some(true),
    };
    let result = create_greeting(&amp;input);
    assert!(result.contains(&quot;Good day&quot;));
    assert!(result.contains(&quot;Dr. Smith&quot;));
}

#[test]
fn test_explicit_informal() {
    let input = GreetInput {
        name: &quot;Bob&quot;.to_string(),
        formal: Some(false),
    };
    let result = create_greeting(&amp;input);
    assert!(result.contains(&quot;Hello&quot;));
}

fn create_greeting(input: &amp;GreetInput) -&gt; String {
    if input.formal.unwrap_or(false) {
        format!(&quot;Good day, {}.&quot;, input.name)
    } else {
        format!(&quot;Hello, {}!&quot;, 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?