MCP Tool Structure
Organize MCP tools in per-tool directories with mod, handler, and helpers files, derive schemas from schemars structs, and dispatch via handle_tool_call.
On this page
This document provides detailed reference material for organizing and implementing MCP tools. The patterns here are extracted from production MCP servers in the systemprompt.io codebase.
Why Modular Structure?
MCP tools can grow complex quickly. A modular directory structure provides:
- Separation of concerns — Schemas, handlers, and utilities in separate files
- Testability — Each handler can be unit tested in isolation
- Maintainability — Changes are localized to a single directory
- Discoverability — Tool name matches directory name
Directory Layout
extensions/mcp/{server}/src/tools/
├── mod.rs # Registration and dispatch
├── shared/ # Shared utilities
│ └── mod.rs
└── {tool_name}/ # Each tool
├── mod.rs # Re-exports
├── handler.rs # Implementation
└── helpers.rs # Schemas and utilities
Root mod.rs
The root mod.rs file handles two responsibilities:
- Tool registration — Returns the list of available tools
- Tool dispatch — Routes calls to the correct handler
use rmcp::model::{CallToolRequestParams, CallToolResult, Tool};
use rmcp::ErrorData as McpError;
use std::sync::Arc;
// Import tool modules
pub mod research_blog;
pub mod create_blog_post;
pub mod shared;
// Re-export for external use
pub use research_blog::handle as handle_research_blog;
/// Returns all tools with their schemas
pub fn list_tools() -> Vec<Tool> {
vec![
create_tool(
"research_blog",
"Research Blog Topic",
"Research a topic using Google Search.",
research_blog::input_schema(),
research_blog::output_schema(),
),
]
}
/// Routes tool calls to handlers
pub async fn handle_tool_call(
name: &str,
request: CallToolRequestParams,
// ... service dependencies
) -> Result<CallToolResult, McpError> {
match name {
"research_blog" => research_blog::handle(/* args */).await,
_ => Err(McpError::invalid_params(
format!("Unknown tool: '{name}'"),
None,
)),
}
}
Tool mod.rs
Each tool's mod.rs is minimal, just re-exporting:
mod handler;
mod helpers;
pub use handler::handle;
pub use helpers::{input_schema, output_schema};
Schema Definitions
Schemas are derived from typed structs with schemars, not hand-written JSON blobs. Define an input struct, derive JsonSchema, and generate the schema at registration time. Doc comments become property descriptions.
Input Schema
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ResearchBlogInput {
/// The topic to research
pub topic: String,
/// Optional areas to focus on
#[serde(default)]
pub focus_areas: Vec<String>,
/// Maximum results (default: 10)
#[serde(default)]
pub limit: Option<u32>,
}
#[must_use]
pub fn input_schema() -> serde_json::Value {
schemars::schema_for!(ResearchBlogInput).to_value()
}
Deserializing arguments into the same struct gives you validation for free: a missing or mistyped field fails at the deserialization boundary instead of deep inside the handler.
Output Schema
Tools that produce artifacts return the shared ToolResponse envelope, parameterized by the artifact type:
use systemprompt::models::artifacts::{CliArtifact, ToolResponse};
#[must_use]
pub fn output_schema() -> serde_json::Value {
ToolResponse::<CliArtifact>::schema()
}
For custom output shapes, derive JsonSchema on an output struct and use schemars::schema_for! exactly as with inputs.
Schema Best Practices
| Practice | How with schemars |
|---|---|
| Mark optional fields | Option<T> or #[serde(default)] (everything else is required) |
| Add descriptions to all properties | Doc comments on struct fields |
| Use enums for constrained values | A Rust enum deriving JsonSchema |
| Document defaults | #[serde(default)] plus the default in the doc comment |
| Type array items | Vec<String> and friends |
| Use typed identifiers | Core id types (SkillId, TaskId, ...) implement JsonSchema |
| Set bounds for numbers | #[schemars(range(min = 1, max = 100))] |
Handler Implementation
Handlers live in handler.rs and follow a consistent pattern.
CRITICAL: All handlers must receive RequestContext from the server's call_tool method. This context contains task_id required for artifact persistence. See MCP Extensions for the full RBAC pattern.
Handler Signature
use anyhow::Result;
use rmcp::model::{CallToolRequestParams, CallToolResult, Content};
use rmcp::ErrorData as McpError;
use serde_json::json;
use std::sync::Arc;
use systemprompt::agent::repository::content::ArtifactRepository;
use systemprompt::agent::services::SkillService;
use systemprompt::ai::AiService;
use systemprompt::database::DbPool;
use systemprompt::identifiers::{McpExecutionId, SkillId};
use systemprompt::mcp::ProgressCallback;
use systemprompt::models::execution::context::RequestContext;
pub async fn handle(
db_pool: &DbPool,
request: CallToolRequestParams,
ctx: RequestContext, // CRITICAL: Contains task_id for artifact persistence
ai_service: &Arc<AiService>,
skill_loader: &SkillService,
artifact_repo: &ArtifactRepository,
progress: Option<ProgressCallback>,
mcp_execution_id: &McpExecutionId, // For tracking and ToolResponse
) -> Result<CallToolResult, McpError> {
// Implementation
}
Handler Structure
A well-structured handler follows this flow:
pub async fn handle(/* params */) -> Result<CallToolResult, McpError> {
// 1. Report initial progress
if let Some(ref notify) = progress {
notify(0.0, Some(100.0), Some("Starting...".to_string())).await;
}
// 2. Extract and validate arguments
let args = request.arguments.as_ref().ok_or_else(|| {
McpError::invalid_request("Missing arguments", None)
})?;
let topic = args
.get("topic")
.and_then(|v| v.as_str())
.ok_or_else(|| {
McpError::invalid_params("Missing required parameter: topic", None)
})?;
// 3. Load dependencies (skills, etc.)
let skill_content = skill_loader
.load_skill(&SkillId::new("research_blog"), &ctx)
.await
.map_err(|e| McpError::internal_error(format!("Skill error: {e}"), None))?;
// 4. Execute business logic
if let Some(ref notify) = progress {
notify(30.0, Some(100.0), Some("Processing...".to_string())).await;
}
let result = do_work(topic, &skill_content).await?;
// 5. Create artifact (if applicable)
if let Some(ref notify) = progress {
notify(80.0, Some(100.0), Some("Saving artifact...".to_string())).await;
}
let artifact_id = create_and_store_artifact(artifact_repo, &result).await?;
// 6. Return result
if let Some(ref notify) = progress {
notify(100.0, Some(100.0), Some("Complete".to_string())).await;
}
Ok(CallToolResult {
content: vec![Content::text(format!("Done. Artifact: {artifact_id}"))],
structured_content: Some(json!({
"artifact_id": artifact_id,
"status": "completed"
})),
is_error: Some(false),
meta: None,
})
}
Parameter Extraction
Required Parameters
let args = request.arguments.as_ref().ok_or_else(|| {
McpError::invalid_request("Missing arguments", None)
})?;
// String parameter
let topic = args
.get("topic")
.and_then(|v| v.as_str())
.ok_or_else(|| {
McpError::invalid_params("Missing required parameter: topic", None)
})?;
// Integer parameter
let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.ok_or_else(|| {
McpError::invalid_params("Missing required parameter: limit", None)
})? as usize;
Optional Parameters
// Optional string with default
let format = args
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("markdown");
// Optional integer with default
let limit = args
.get("limit")
.and_then(|v| v.as_i64())
.unwrap_or(10) as usize;
Array Parameters
Use a helper function:
pub fn extract_string_array(
args: &serde_json::Map<String, serde_json::Value>,
key: &str,
) -> Vec<String> {
args.get(key)
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
// Usage
let focus_areas = extract_string_array(args, "focus_areas");
Enum Validation
let skill_id = args
.get("skill_id")
.and_then(|v| v.as_str())
.ok_or_else(|| {
McpError::invalid_params("Missing required parameter: skill_id", None)
})?;
if skill_id != "research_blog" {
return Err(McpError::invalid_params(
format!("Invalid skill_id: '{skill_id}'. Must be 'research_blog'."),
None,
));
}
Shared Utilities
Common helpers go in tools/shared/mod.rs:
use serde_json::{Map, Value};
/// Extract string array from arguments
pub fn extract_string_array(args: &Map<String, Value>, key: &str) -> Vec<String> {
args.get(key)
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default()
}
/// Extract optional string
pub fn extract_optional_string(args: &Map<String, Value>, key: &str) -> Option<String> {
args.get(key).and_then(|v| v.as_str()).map(String::from)
}
/// Extract integer with default
pub fn extract_int_or_default(args: &Map<String, Value>, key: &str, default: i64) -> i64 {
args.get(key).and_then(|v| v.as_i64()).unwrap_or(default)
}
/// Extract boolean with default
pub fn extract_bool_or_default(args: &Map<String, Value>, key: &str, default: bool) -> bool {
args.get(key).and_then(|v| v.as_bool()).unwrap_or(default)
}
Error Handling
Use appropriate McpError types:
// Client provided invalid parameters
Err(McpError::invalid_params("Missing required parameter: topic", None))
// Request structure is invalid
Err(McpError::invalid_request("Arguments must be provided", None))
// Server-side error
Err(McpError::internal_error(format!("Database error: {e}"), None))
// Tool not found
Err(McpError::method_not_found::<CallToolRequestMethod>())
Testing Tools
Unit Test Handler
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_handle_valid_input() {
let args = serde_json::json!({
"topic": "Rust async",
"skill_id": "research_blog"
});
let request = CallToolRequestParams {
name: "research_blog".into(),
arguments: Some(args.as_object().unwrap().clone()),
meta: None,
};
// Mock dependencies and call handler
let result = handle(/* mocked deps */, request, /* ... */).await;
assert!(result.is_ok());
let result = result.unwrap();
assert_eq!(result.is_error, Some(false));
}
}
File Reference
| File | Purpose |
|---|---|
tools/mod.rs |
list_tools(), handle_tool_call() |
tools/{name}/mod.rs |
Re-exports handler and schemas |
tools/{name}/handler.rs |
pub async fn handle() |
tools/{name}/helpers.rs |
input_schema(), output_schema(), utilities |
tools/shared/mod.rs |
Common extraction helpers |