5 releases
Uses new Rust 2024
| 0.2.3 | Jul 18, 2025 |
|---|---|
| 0.2.2 | Jul 18, 2025 |
| 0.2.1 | Jul 18, 2025 |
| 0.2.0 | Jul 16, 2025 |
| 0.1.0 | Jun 29, 2025 |
#545 in Parser implementations
189 downloads per month
Used in 5 crates
44KB
747 lines
MCPlease
MCPlease is a lightweight Rust framework for building MCP (Model Context Protocol) servers. It provides a simple, macro-driven approach to defining tools and managing state, with optional support for session persistence and cross-process synchronization.
Features
- Simple tool definition using the
tools!macro - Automatic JSON Schema generation from Rust structs using
schemars - Session management with cross-process synchronization via file watching
- Command-line interface with automatic help generation via
clap - Example system for better tool documentation
- Stdio-based MCP communication (WebSocket support planned)
- Code generation CLI for rapid development
Quick Start with CLI (Recommended)
The fastest way to get started is with the mcplease CLI tool:
# Install the CLI
cargo install mcplease-cli
# Create a new MCP server with tools
mcplease create my-server --tools hello,goodbye,status --state MyServerState
# Navigate to your project
cd my-server
# Add more tools as needed
mcplease add health_check
mcplease add ping
# Test that it compiles
cargo check
# Run your MCP server
cargo run serve
This creates a fully functional MCP server with:
- ✅ Proper project structure
- ✅ Generated tool implementations (with TODOs for you to fill in)
- ✅ State management boilerplate
- ✅ All necessary dependencies
- ✅ Beautifully formatted code
For detailed CLI documentation, see cli/README.md
Manual Setup
1. Create a new MCP server project
cargo new my-mcp-server
cd my-mcp-server
2. Add dependencies to Cargo.toml
[dependencies]
anyhow = "1.0"
clap = { version = "4.5", features = ["derive"] }
fieldwork = "0.4.6"
mcplease = "0.2.0"
schemars = "1.0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
3. Define your state structure
Create src/state.rs:
use anyhow::Result;
use mcplease::session::SessionStore;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct SharedData {
pub working_directory: Option<PathBuf>,
// Add other shared state fields here
}
#[derive(Debug, fieldwork::Fieldwork)]
pub struct MyToolsState {
#[fieldwork(get, get_mut)]
session_store: SessionStore<SharedData>,
}
impl MyToolsState {
pub fn new() -> Result<Self> {
let session_store = SessionStore::new(Some(
dirs::home_dir()
.unwrap_or_default()
.join(".ai-tools/sessions/my-tools.json")
))?;
Ok(Self { session_store })
}
pub fn get_working_directory(&mut self) -> Result<Option<PathBuf>> {
Ok(self.session_store.get_or_create("default")?.working_directory.clone())
}
pub fn set_working_directory(&mut self, path: PathBuf) -> Result<()> {
self.session_store.update("default", |data| {
data.working_directory = Some(path);
})
}
}
4. Create tools
Create src/tools/ directory and add tool implementations. Each tool should be in its own module:
src/tools/hello.rs:
use crate::state::MyToolsState;
use anyhow::Result;
use mcplease::{
traits::{Tool, WithExamples},
types::Example,
};
use serde::{Deserialize, Serialize};
/// Say hello to someone
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema, clap::Args)]
#[serde(rename = "hello")]
pub struct Hello {
/// The name to greet
pub name: String,
/// Whether to be enthusiastic
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(long)]
pub enthusiastic: Option<bool>,
}
impl WithExamples for Hello {
fn examples() -> Vec<Example<Self>> {
vec![
Example {
description: "A simple greeting",
item: Self {
name: "World".into(),
enthusiastic: None,
},
},
Example {
description: "An enthusiastic greeting",
item: Self {
name: "Alice".into(),
enthusiastic: Some(true),
},
},
]
}
}
impl Tool<MyToolsState> for Hello {
fn execute(self, _state: &mut MyToolsState) -> Result<String> {
let greeting = if self.enthusiastic.unwrap_or(false) {
format!("Hello, {}! 🎉", self.name)
} else {
format!("Hello, {}", self.name)
};
Ok(greeting)
}
}
src/tools/set_working_directory.rs:
use crate::state::MyToolsState;
use anyhow::Result;
use mcplease::{
traits::{Tool, WithExamples},
types::Example,
};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Set the working directory for relative path operations
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema, clap::Args)]
#[serde(rename = "set_working_directory")]
pub struct SetWorkingDirectory {
/// New working directory path
pub path: String,
}
impl WithExamples for SetWorkingDirectory {
fn examples() -> Vec<Example<Self>> {
vec![
Example {
description: "Set working directory to a project folder",
item: Self {
path: "/path/to/my/project".into(),
},
},
]
}
}
impl Tool<MyToolsState> for SetWorkingDirectory {
fn execute(self, state: &mut MyToolsState) -> Result<String> {
let path = PathBuf::from(&*shellexpand::tilde(&self.path));
if !path.exists() {
return Ok(format!("Directory {} does not exist", path.display()));
}
state.set_working_directory(path.clone())?;
Ok(format!("Set working directory to {}", path.display()))
}
}
5. Wire everything together
src/tools.rs:
use crate::state::MyToolsState;
mcplease::tools!(
MyToolsState,
(Hello, hello, "hello"),
(SetWorkingDirectory, set_working_directory, "set_working_directory")
);
src/main.rs:
mod state;
mod tools;
use anyhow::Result;
use mcplease::server_info;
use state::MyToolsState;
const INSTRUCTIONS: &str = "This is my custom MCP server. Use set_working_directory to establish context.";
fn main() -> Result<()> {
let mut state = MyToolsState::new()?;
mcplease::run::<tools::Tools, _>(&mut state, server_info!(), Some(INSTRUCTIONS))
}
6. Run your server
# Run as MCP server (stdio mode)
cargo run serve
# Or use tools directly from command line
cargo run hello --name "World"
cargo run set-working-directory --path "/tmp"
Framework Architecture
Core Components
tools!macro: Generates the enum that implements MCP tool dispatchTooltrait: Defines how individual tools executeWithExamplestrait: Provides example usage for documentationSessionStore: Handles persistent state with cross-process sync- JSON Schema generation: Automatic from Rust structs via
schemars
Tool Definition Pattern
Each tool follows this pattern:
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema, clap::Args)]
#[serde(rename = "tool_name")]
pub struct MyTool {
// Tool parameters with proper documentation
/// Description of the parameter
pub required_param: String,
/// Optional parameter with skip_serializing_if
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(long)]
pub optional_param: Option<bool>,
}
impl WithExamples for MyTool { /* ... */ }
impl Tool<StateType> for MyTool { /* ... */ }
State Management
The framework uses SessionStore<T> for persistent state:
- Cross-process safe: File watching detects external changes
- Atomic writes: Temporary file + rename prevents corruption
- Session-based: Multiple sessions can coexist
- JSON serialization: Human-readable storage format
Session Store API
// Get or create session data
let data = store.get_or_create("session_id")?;
// Update data with closure
store.update("session_id", |data| {
data.some_field = new_value;
})?;
// Get without creating
let maybe_data = store.get("session_id")?;
// Set directly
store.set("session_id", new_data)?;
Advanced Features
Error Handling
Tools should return anyhow::Result<String> for consistent error propagation:
impl Tool<State> for MyTool {
fn execute(self, state: &mut State) -> Result<String> {
// Use ? for error propagation
let data = std::fs::read_to_string(&self.path)
.with_context(|| format!("Failed to read {}", self.path))?;
// Return success message
Ok(format!("Successfully processed {} bytes", data.len()))
}
}
Examples and Documentation
Provide meaningful examples to help users understand tool usage:
impl WithExamples for MyTool {
fn examples() -> Vec<Example<Self>> {
vec![
Example {
description: "Basic usage with default settings",
item: Self {
path: "example.txt".into(),
options: None,
},
},
Example {
description: "Advanced usage with custom options",
item: Self {
path: "/absolute/path/file.txt".into(),
options: Some(CustomOptions { verbose: true }),
},
},
]
}
}
Optional Parameters
Use Option<T> with proper serialization handling:
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema, clap::Args)]
pub struct MyTool {
/// Required parameter
pub required: String,
/// Optional parameter (won't appear in JSON if None)
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(long)]
pub optional: Option<String>,
/// Boolean flag (defaults to false)
#[serde(skip_serializing_if = "Option::is_none")]
#[arg(long, action = clap::ArgAction::SetTrue)]
pub flag: Option<bool>,
}
impl MyTool {
fn flag(&self) -> bool {
self.flag.unwrap_or(false)
}
}
Shared Session Data
For tools that need to share context across processes:
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct SharedContext {
pub working_directory: Option<PathBuf>,
pub recent_files: Vec<PathBuf>,
pub user_preferences: HashMap<String, String>,
}
// In your state struct:
impl MyState {
pub fn new() -> Result<Self> {
// Use a shared file for cross-server communication
let shared_store = SessionStore::new(Some(
dirs::home_dir()
.unwrap_or_default()
.join(".ai-tools/sessions/shared-context.json")
))?;
Ok(Self { shared_store })
}
}
Best Practices
Tool Design
- Single responsibility: Each tool should do one thing well
- Clear documentation: Use detailed doc comments on all parameters
- Meaningful examples: Provide realistic usage scenarios
- Error context: Use
anyhow::Contextfor descriptive error messages - Defensive programming: Validate inputs and handle edge cases
State Management
- Minimal state: Only persist what's necessary across calls
- Default values: Use
#[serde(default)]for backward compatibility - Session IDs: Use logical identifiers like "default", project names, etc.
- Cleanup: Consider implementing state cleanup for old sessions
Error Messages
Return user-friendly messages that help with debugging:
// Good: Specific and actionable
Ok(format!("File {} does not exist. Use an absolute path or set_working_directory first.", path))
// Bad: Generic and unhelpful
Err(anyhow!("File not found"))
Path Handling
Use consistent path resolution patterns:
fn resolve_path(base: Option<&Path>, input: &str) -> Result<PathBuf> {
let path = PathBuf::from(&*shellexpand::tilde(input));
if path.is_absolute() {
Ok(path)
} else if let Some(base) = base {
Ok(base.join(path))
} else {
Err(anyhow!("Relative path requires working directory to be set"))
}
}
Debugging
Logging
Set MCP_LOG_LOCATION environment variable to enable logging:
export MCP_LOG_LOCATION="~/.ai-tools/logs/my-server.log"
cargo run serve
Log levels: RUST_LOG=trace,warn,error,debug,info
Testing Tools Directly
Use the command-line interface for testing:
# Test individual tools
cargo run my-tool --param value
# Get help
cargo run help
cargo run my-tool --help
Common Issues
- Schema validation errors: Ensure all fields have proper serde attributes
- Session conflicts: Use unique session IDs for different contexts
- Path resolution: Always handle both absolute and relative paths
- JSON parsing: Check that tool parameters match expected schema
Contributing
When adding new tools to existing servers:
- Create a new module in
src/tools/ - Implement the required traits
- Add to the
tools!macro insrc/tools.rs - Add tests in
src/tests.rs - Update documentation and examples
The framework is designed to be extensible - new MCP servers should follow the established patterns for consistency and maintainability.
Dependencies
~2–14MB
~100K SLoC