MCP Tool Structure

Detailed reference for organizing tools with modular directory patterns, handler signatures, and schema definitions.

This document provides detailed reference material for organizing and implementing MCP tools. The patterns here are extracted from production MCP servers in the SystemPrompt 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 use JSON Schema format and live in helpers.rs:

Input Schema

use serde_json::json;

pub fn input_schema() -> serde_json::Value {
    json!({
        "type": "object",
        "properties": {
            "topic": {
                "type": "string",
                "description": "The topic to research"
            },
            "skill_id": {
                "type": "string",
                "description": "Must be 'research_blog'",
                "enum": ["research_blog"]
            },
            "focus_areas": {
                "type": "array",
                "items": {"type": "string"},
                "description": "Optional areas to focus on"
            },
            "limit": {
                "type": "integer",
                "description": "Maximum results (default: 10)",
                "default": 10,
                "minimum": 1,
                "maximum": 100
            }
        },
        "required": ["topic", "skill_id"]
    })
}

Output Schema

pub fn output_schema() -> serde_json::Value {
    json!({
        "type": "object",
        "properties": {
            "artifact_id": {
                "type": "string",
                "format": "uuid",
                "description": "UUID to pass to subsequent tools"
            },
            "topic": {
                "type": "string"
            },
            "source_count": {
                "type": "integer"
            },
            "status": {
                "type": "string",
                "enum": ["completed", "partial", "error"]
            }
        },
        "required": ["artifact_id", "status"]
    })
}

Schema Best Practices

Practice Example
Always specify required "required": ["topic", "skill_id"]
Add descriptions to all properties "description": "The topic to research"
Use enum for constrained values "enum": ["option_a", "option_b"]
Document defaults "default": 10 or in description
Specify array item types "items": {"type": "string"}
Use format hints "format": "uuid", "format": "uri"
Set bounds for numbers "minimum": 1, "maximum": 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;
use systemprompt::models::execution::context::RequestContext;

use crate::server::ProgressCallback;

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("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