Skip to main content

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.

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:

  1. Tool registration — Returns the list of available tools
  2. 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