3 unstable releases
Uses new Rust 2024
| new 0.2.1 | Oct 20, 2025 |
|---|---|
| 0.2.0 | Sep 30, 2025 |
| 0.1.1 | Sep 3, 2025 |
#1762 in Procedural macros
165 downloads per month
Used in turul-mcp-aws-lambda
635KB
13K
SLoC
turul-mcp-derive
Procedural macros for the turul-mcp-framework, providing zero-configuration creation of MCP tools, resources, and prompts with automatic schema generation.
Overview
turul-mcp-derive provides macro approaches for creating MCP components:
Tools
- Function Macros (
#[mcp_tool]) - Ultra-simple tool creation from functions - Derive Macros (
#[derive(McpTool)]) - Struct-based tools with complex logic
Resources
- Function Macros (
#[mcp_resource]) - Create resources from async functions with URI templates
Prompts
- Derive Macros (
#[derive(McpPrompt)]) - Structured prompt definitions with arguments
All approaches generate required traits automatically and provide compile-time validation.
Features
- ✅ Zero Configuration - No method strings, framework auto-determines everything
- ✅ Compile-time Validation - Schema errors caught at compile time
- ✅ SessionContext Support - Automatic session context passing
- ✅ Type Safety - Full Rust type system integration
- ✅ JSON Schema Generation - Automatic OpenAPI-compatible schemas
- ✅ Custom Output Fields - Configurable output field names
Function Macros - Level 1
Basic Function Tool
use turul_mcp_derive::mcp_tool;
use turul_mcp_server::McpResult;
#[mcp_tool(name = "calculator", description = "Add two numbers")]
async fn calculator(
#[param(description = "First number")] a: f64,
#[param(description = "Second number")] b: f64,
) -> McpResult<f64> {
Ok(a + b)
}
// Usage: Just pass the function to the server
let server = McpServer::builder()
.name("calculator-server")
.version("1.0.0")
.tool_fn(calculator) // Framework knows the function name!
.bind_address("127.0.0.1:8641".parse()?) // Default port
.build()?;
server.run().await
SessionContext Integration
use turul_mcp_derive::mcp_tool;
use turul_mcp_server::{McpResult, SessionContext};
#[mcp_tool(name = "counter", description = "Session-persistent counter")]
async fn session_counter(
session: Option<SessionContext> // Automatically detected by macro
) -> McpResult<i32> {
if let Some(session) = session {
let count: i32 = session.get_typed_state("count").await.unwrap_or(0);
let new_count = count + 1;
session.set_typed_state("count", new_count).await
.map_err(|e| format!("Failed to save state: {}", e))?;
Ok(new_count)
} else {
Ok(0) // No session available
}
}
Custom Output Fields
Note: output_field only affects structured output generation - the field name used when the return value is wrapped in a JSON object for structured responses.
#[mcp_tool(
name = "multiply",
description = "Multiply two numbers",
output_field = "product" // Custom output field name (also supports: field = "product")
)]
async fn multiply(
#[param(description = "First number")] x: f64,
#[param(description = "Second number")] y: f64,
) -> McpResult<f64> {
Ok(x * y) // Returns {"product": 15.0} instead of {"result": 15.0} in structured output
}
Array Return Types
Important: When returning Vec<T>, you must use the output attribute to ensure correct JSON Schema generation:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize)]
struct SearchResult {
id: String,
title: String,
score: f64,
}
#[mcp_tool(
name = "search",
description = "Search for items",
output = Vec<SearchResult> // Required for array returns!
)]
async fn search(
#[param(description = "Search query")] query: String,
) -> McpResult<Vec<SearchResult>> {
Ok(vec![
SearchResult {
id: "1".to_string(),
title: format!("Result for: {}", query),
score: 0.95,
}
])
}
// Without `output = Vec<T>`, the schema would incorrectly show type: "object"
// With `output = Vec<T>`, the schema correctly shows type: "array"
Progress Notifications
#[mcp_tool(name = "slow_task", description = "Task with progress updates")]
async fn slow_task(
#[param(description = "Number of steps")] steps: u32,
session: Option<SessionContext>,
) -> McpResult<String> {
for i in 1..=steps {
if let Some(ref session) = session {
session.notify_progress("slow-task", i as u64).await;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
Ok(format!("Completed {} steps", steps))
}
Derive Macros - Level 2
Basic Struct Tool
use turul_mcp_derive::McpTool;
use turul_mcp_server::{McpResult, SessionContext};
#[derive(McpTool, Clone, Default)]
#[tool(name = "calculator", description = "Advanced calculator", output_field = "result")]
struct Calculator {
#[param(description = "First number")]
a: f64,
#[param(description = "Second number")]
b: f64,
#[param(description = "Operation type")]
operation: String,
}
impl Calculator {
async fn execute(&self, _session: Option<SessionContext>) -> McpResult<f64> {
match self.operation.as_str() {
"add" => Ok(self.a + self.b),
"multiply" => Ok(self.a * self.b),
"subtract" => Ok(self.a - self.b),
"divide" => {
if self.b == 0.0 {
Err("Division by zero".into())
} else {
Ok(self.a / self.b)
}
}
_ => Err("Unsupported operation".into())
}
}
}
Array Return Types with Derive
For tools that return Vec<T>, use the output attribute in the #[tool(...)] annotation:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone)]
struct Record {
id: u32,
value: String,
}
#[derive(McpTool, Clone, Default)]
#[tool(
name = "fetch_records",
description = "Fetch multiple records",
output = Vec<Record> // Required for correct array schema generation
)]
struct RecordFetcher {
#[param(description = "Maximum number of records")]
limit: usize,
}
impl RecordFetcher {
async fn execute(&self, _session: Option<SessionContext>) -> McpResult<Vec<Record>> {
Ok((0..self.limit)
.map(|i| Record {
id: i as u32,
value: format!("record-{}", i),
})
.collect())
}
}
// This generates correct JSON Schema:
// {"output": {"type": "array", "items": {...}}}
// Without `output = Vec<Record>`, it would incorrectly generate:
// {"output": {"type": "object"}}
Complex Business Logic
#[derive(McpTool, Clone, Default)]
#[tool(name = "user_lookup", description = "Look up user information")]
struct UserLookupTool {
#[param(description = "User ID to lookup")]
user_id: String,
#[param(description = "Include detailed profile")]
include_details: Option<bool>,
}
impl UserLookupTool {
async fn execute(&self, session: Option<SessionContext>) -> McpResult<serde_json::Value> {
// Complex business logic with database queries
let include_details = self.include_details.unwrap_or(false);
if let Some(session) = session {
session.notify_progress("lookup", 25).await;
}
// Simulate database lookup
let user = self.lookup_user_in_database(&self.user_id).await?;
if let Some(session) = session {
session.notify_progress("lookup", 75).await;
}
let result = if include_details {
self.get_detailed_profile(user).await?
} else {
self.get_basic_profile(user).await?
};
if let Some(session) = session {
session.notify_progress("lookup", 100).await;
}
Ok(result)
}
async fn lookup_user_in_database(&self, user_id: &str) -> McpResult<User> {
// Database implementation
todo!()
}
async fn get_detailed_profile(&self, user: User) -> McpResult<serde_json::Value> {
// Detailed profile logic
todo!()
}
async fn get_basic_profile(&self, user: User) -> McpResult<serde_json::Value> {
// Basic profile logic
todo!()
}
}
Advanced Parameter Types
Supported Parameter Types
#[derive(McpTool, Clone, Default)]
#[tool(name = "complex_params", description = "Demonstrate all parameter types")]
struct ComplexParamsTool {
// Basic types
#[param(description = "String parameter")]
text: String,
#[param(description = "Integer parameter")]
number: i32,
#[param(description = "Float parameter")]
decimal: f64,
#[param(description = "Boolean parameter")]
flag: bool,
// Optional types
#[param(description = "Optional string")]
optional_text: Option<String>,
#[param(description = "Optional number")]
optional_number: Option<i32>,
// Collections
#[param(description = "List of strings")]
string_list: Vec<String>,
#[param(description = "List of numbers")]
number_list: Vec<i32>,
// Complex nested types
#[param(description = "JSON object parameter")]
json_data: serde_json::Value,
}
Parameter Validation
#[derive(McpTool, Clone, Default)]
#[tool(name = "validated_tool", description = "Tool with parameter validation")]
struct ValidatedTool {
#[param(description = "Email address")]
email: String,
#[param(description = "Age in years (1-120)")]
age: u8,
}
impl ValidatedTool {
async fn execute(&self, _session: Option<SessionContext>) -> McpResult<String> {
// Validation logic
if !self.email.contains('@') {
return Err("Invalid email format".into());
}
if self.age == 0 || self.age > 120 {
return Err("Age must be between 1 and 120".into());
}
Ok(format!("Valid user: {} (age {})", self.email, self.age))
}
}
Schema Generation
Automatic JSON Schema
The macros automatically generate JSON Schema compatible with MCP Inspector:
// This struct:
#[derive(McpTool, Clone, Default)]
#[tool(name = "example", description = "Example tool")]
struct ExampleTool {
#[param(description = "Required string")]
name: String,
#[param(description = "Optional number")]
count: Option<i32>,
}
// Generates this JSON Schema automatically:
{
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Required string"
},
"count": {
"type": "integer",
"description": "Optional number"
}
},
"required": ["name"],
"additionalProperties": false
}
Custom Schema Attributes
#[derive(McpTool, Clone, Default)]
#[tool(name = "constrained", description = "Tool with schema constraints")]
struct ConstrainedTool {
#[param(description = "Number between 1 and 100")]
percentage: f64, // Could add custom validation attributes in future
}
Error Handling
Compile-time Validation
The macros provide helpful compile-time error messages:
// This will fail to compile with helpful error:
#[mcp_tool(name = "bad_tool")] // Missing description!
async fn bad_tool() -> McpResult<String> {
Ok("test".to_string())
}
// Error: tool attribute must include description
Runtime Error Integration
use turul_mcp_server::McpError;
#[mcp_tool(name = "error_example", description = "Demonstrate error handling")]
async fn error_example(
#[param(description = "Value to validate")] value: i32
) -> McpResult<String> {
if value < 0 {
return Err(McpError::InvalidParameters("Value must be non-negative".to_string()));
}
if value > 1000 {
return Err("Value too large".into()); // String automatically converts to McpError
}
Ok(format!("Valid value: {}", value))
}
Generated Code
What the Macros Generate
For a function tool, the macro generates:
- A wrapper struct implementing
ToolDefinition - All required fine-grained traits (
HasBaseMetadata,HasDescription, etc.) McpTooltrait implementation with parameter extraction- JSON Schema generation code
- Error handling and response wrapping
Integration with Framework
The generated code integrates seamlessly with the framework's trait system:
// Your code:
#[mcp_tool(name = "example", description = "Example")]
async fn example() -> McpResult<String> { Ok("test".to_string()) }
// Generated code (simplified):
struct ExampleTool;
impl HasBaseMetadata for ExampleTool {
fn name(&self) -> &str { "example" }
}
impl HasDescription for ExampleTool {
fn description(&self) -> Option<&str> { Some("Example") }
}
impl McpTool for ExampleTool {
async fn call(&self, args: Value, session: Option<SessionContext>) -> McpResult<CallToolResult> {
// Generated parameter extraction and function call
let result = example(/* extracted params */).await?;
Ok(CallToolResult::success(/* wrapped result */))
}
}
Testing Macros
Unit Testing Tools
#[cfg(test)]
mod tests {
use super::*;
use turul_mcp_server::SessionContext;
#[tokio::test]
async fn test_calculator_tool() {
let tool = Calculator {
a: 5.0,
b: 3.0,
operation: "add".to_string(),
};
let result = tool.execute(None).await
.expect("Tool execution should succeed");
assert_eq!(result, 8.0);
}
}
Integration Testing
#[tokio::test]
async fn test_tool_in_server() {
let server = McpServer::builder()
.tool_fn(calculator)
.build()
.expect("Server should build successfully");
// Test server integration
// (requires test infrastructure)
}
Debugging
Macro Expansion
To see what code the macros generate:
# View expanded macros
cargo expand --package your-package
# View specific function
cargo expand your_function_name
Common Issues
- Missing SessionContext: Function macros auto-detect
SessionContextparameters by type - Parameter Names: Use exactly the same parameter names in function signature and schema
- Return Types: Must return
McpResult<T>whereTimplementsSerialize
Prompts - Level 3
Basic Prompt Derive
use turul_mcp_derive::McpPrompt;
use turul_mcp_server::{McpResult, McpPrompt};
use async_trait::async_trait;
#[derive(McpPrompt, Clone, Serialize, Deserialize, Debug)]
#[prompt(name = "code_review", description = "Review code for quality and security")]
struct CodeReviewPrompt {
#[argument(description = "Programming language")]
language: String,
#[argument(description = "Code to review")]
code: String,
#[argument(description = "Review focus (optional)")]
focus: Option<String>,
}
// Users must implement McpPrompt manually for custom render logic
#[async_trait]
impl McpPrompt for CodeReviewPrompt {
async fn render(&self, _args: Option<HashMap<String, Value>>) -> McpResult<Vec<PromptMessage>> {
let focus = self.focus.as_deref().unwrap_or("comprehensive");
let prompt = format!(
"You are an expert {} developer. Provide {} code review for:\n\n{}",
self.language, focus, self.code
);
Ok(vec![PromptMessage::user_text(&prompt)])
}
}
// Server usage
let server = McpServer::builder()
.name("prompt-server")
.version("1.0.0")
.prompt(CodeReviewPrompt::default())
.bind_address("127.0.0.1:8641".parse()?) // Default port
.build()?;
server.run().await
Default vs Custom Render
The #[derive(McpPrompt)] macro generates metadata traits only. For custom behavior:
Option 1: Use Default Render (for simple prompts)
#[derive(McpPrompt)]
#[prompt(name = "simple", description = "Simple prompt")]
struct SimplePrompt;
// No manual implementation needed - uses trait default
// Returns: "Prompt: simple - Simple prompt"
Option 2: Custom Render (for complex prompts)
#[derive(McpPrompt)]
#[prompt(name = "database_query", description = "Query database and format results")]
struct DatabasePrompt {
#[argument(description = "SQL query")]
query: String,
}
#[async_trait]
impl McpPrompt for DatabasePrompt {
async fn render(&self, args: Option<HashMap<String, Value>>) -> McpResult<Vec<PromptMessage>> {
// Custom database logic
let results = db.execute(&self.query).await?;
let formatted = format_results(results);
Ok(vec![PromptMessage::user_text(&formatted)])
}
}
Prompt Patterns
Template Substitution
#[async_trait]
impl McpPrompt for TemplatePrompt {
async fn render(&self, args: Option<HashMap<String, Value>>) -> McpResult<Vec<PromptMessage>> {
let args = args.unwrap_or_default();
let user_name = args.get("user_name").and_then(|v| v.as_str()).unwrap_or("User");
let message = format!("Hello {}! {}", user_name, self.template);
Ok(vec![PromptMessage::user_text(&message)])
}
}
Multi-Modal Content
#[async_trait]
impl McpPrompt for MultiModalPrompt {
async fn render(&self, _args: Option<HashMap<String, Value>>) -> McpResult<Vec<PromptMessage>> {
Ok(vec![
PromptMessage::user_text("Analyze this image:"),
PromptMessage {
role: Role::User,
content: ContentBlock::Image {
data: self.image_data.clone(),
mime_type: "image/png".to_string(),
},
},
])
}
}
Resources - Level 3
Resource Function Macro
The #[mcp_resource] function attribute macro allows creating MCP resources from regular async functions with automatic URI template detection and parameter extraction.
use turul_mcp_derive::mcp_resource;
use turul_mcp_server::{McpResult, McpServer};
use turul_mcp_protocol::resources::ResourceContent;
// Static resource - no template variables
#[mcp_resource(
uri = "file:///config.json",
name = "config",
description = "Application configuration file"
)]
async fn get_config() -> McpResult<Vec<ResourceContent>> {
let config = serde_json::json!({
"app_name": "My App",
"version": "1.0.0",
"debug": true
});
Ok(vec![ResourceContent::blob(
"file:///config.json",
serde_json::to_string_pretty(&config).unwrap(),
"application/json".to_string()
)])
}
// Template resource with automatic parameter extraction
#[mcp_resource(
uri = "file:///users/{user_id}.json",
name = "user_profile",
description = "User profile data for a specific user ID"
)]
async fn get_user_profile(user_id: String) -> McpResult<Vec<ResourceContent>> {
let profile = serde_json::json!({
"user_id": user_id,
"username": format!("user_{}", user_id),
"email": format!("user_{}@example.com", user_id)
});
Ok(vec![ResourceContent::blob(
format!("file:///users/{}.json", user_id),
serde_json::to_string_pretty(&profile).unwrap(),
"application/json".to_string()
)])
}
// Resource with additional parameters
#[mcp_resource(
uri = "file:///logs/{log_type}.log",
name = "log_entries",
description = "Log entries filtered by type and level"
)]
async fn get_log_entries(log_type: String, params: serde_json::Value) -> McpResult<Vec<ResourceContent>> {
let level = params.get("level")
.and_then(|v| v.as_str())
.unwrap_or("info");
let entries = format!("2024-01-01 10:00:00 {} [{}] Sample log entry", level.to_uppercase(), log_type);
Ok(vec![ResourceContent::text(
format!("file:///logs/{}.log", log_type),
entries
)])
}
Server Integration with resource_fn
The #[mcp_resource] macro generates both the resource implementation and a constructor function for easy registration:
let server = McpServer::builder()
.name("resource-server")
.version("1.0.0")
.resource_fn(get_config) // Static resource
.resource_fn(get_user_profile) // Template: file:///users/{user_id}.json
.resource_fn(get_log_entries) // Template with params
.bind_address("127.0.0.1:8641".parse()?) // Default port
.build()?;
server.run().await
Template Variable Extraction
The framework automatically extracts template variables from the URI pattern and maps them to function parameters:
// URI: file:///data/{category}/{item_id}.json
#[mcp_resource(
uri = "file:///data/{category}/{item_id}.json",
name = "data_item",
description = "Data item by category and ID"
)]
async fn get_data_item(category: String, item_id: String) -> McpResult<Vec<ResourceContent>> {
// category and item_id are automatically extracted from template_variables
let data = serde_json::json!({
"category": category,
"item_id": item_id,
"content": format!("Data for {} item {}", category, item_id)
});
Ok(vec![ResourceContent::blob(
format!("file:///data/{}/{}.json", category, item_id),
serde_json::to_string_pretty(&data).unwrap(),
"application/json".to_string()
)])
}
Resource Macro Features
- ✅ Automatic McpResource Implementation - Generates all required traits
- ✅ URI Template Detection - Auto-detects
{variable}patterns - ✅ Parameter Extraction - Maps template variables to function parameters
- ✅ Static Resource Support - Handles resources without templates
- ✅ Custom MIME Types - Optional
mime_typeattribute - ✅ Additional Parameters - Supports
params: serde_json::Valuefor extra data
Resource Attributes
#[mcp_resource(
uri = "required://absolute/path", // Required: Absolute URI
name = "resource_name", // Optional: Defaults to function name
description = "Resource description", // Optional: Defaults to generated text
mime_type = "application/json" // Optional: MIME type hint
)]
Alternative: Constructor Function Pattern
For complex resources or when the macro doesn't fit your needs, use the constructor function pattern:
use turul_mcp_server::{McpServer, McpResource};
use turul_mcp_protocol::resources::*;
// Define resource struct
struct ConfigResource;
impl HasResourceMetadata for ConfigResource {
fn name(&self) -> &str { "config" }
}
impl HasResourceUri for ConfigResource {
fn uri(&self) -> &str { "file:///config.json" }
}
// ... implement other required traits
#[async_trait]
impl McpResource for ConfigResource {
async fn read(&self, _params: Option<serde_json::Value>) -> McpResult<Vec<ResourceContent>> {
// Custom implementation
Ok(vec![])
}
}
// Constructor function
fn create_config_resource() -> ConfigResource {
ConfigResource
}
// Register with .resource_fn()
let server = McpServer::builder()
.name("config-server")
.version("1.0.0")
.resource_fn(create_config_resource) // Uses constructor function
.bind_address("127.0.0.1:8641".parse()?) // Default port
.build()?;
server.run().await
Comparison: Macro vs Manual Implementation
| Feature | #[mcp_resource] Macro |
Manual Implementation |
|---|---|---|
| Setup Time | Minimal - just attributes | More setup required |
| Template Variables | Automatic extraction | Manual parameter handling |
| Type Safety | Compile-time validation | Manual validation needed |
| Flexibility | Good for common patterns | Full control over behavior |
| Generated Code | Automatic trait impls | Manual trait implementations |
| Registration | .resource_fn(function_name) |
.resource_fn(constructor) |
Choose the macro for rapid development and standard patterns. Use manual implementation for complex business logic or when you need full control over resource behavior.
Performance
Compile-time vs Runtime
- Schema Generation: Compile-time (zero runtime cost)
- Parameter Extraction: Runtime (optimized JSON parsing)
- Trait Dispatch: Compile-time monomorphization
Memory Usage
- Function tools: Zero-sized types when possible
- Struct tools: Only store necessary parameter data
Compatibility
Rust Version
Requires Rust 1.70+ for async fn in traits support.
Framework Integration
Works with all turul-mcp-framework components:
turul-mcp-server- Core serverturul-mcp-aws-lambda- Lambda integrationturul-mcp-builders- Runtime builders
License
Licensed under the MIT License. See LICENSE for details.
Dependencies
~3–5MB
~93K SLoC