← Back to MCP Tutorials

Building Multi-Tool MCP Servers

Create cohesive MCP servers that expose multiple related tools. Learn to organize tools logically, share state, and build complete tool suites for AI agents.

February 3, 2026·12 min read

Most real-world MCP servers need more than one tool. A database server needs query, insert, update, and delete tools. A file server needs read, write, list, and search tools. Building these multi-tool servers well requires thoughtful organization and state management.

In this tutorial, you'll learn patterns for building MCP servers with multiple related tools that work together cohesively. We'll build a complete project management server as our example.

When to Use Multi-Tool Servers

Group tools into a single server when they:

  • Share state — Tools need access to the same data or connections
  • Have related functionality — CRUD operations on the same resource
  • Share configuration — Same API keys, database connections, etc.
  • Are deployed together — Always used as a set

Keep tools in separate servers when they're truly independent or when you want to enable/disable them individually.

Project Structure

For larger multi-tool servers, organize your code clearly:

project-mcp-server/
├── server.py           # Main entry point
├── tools/
│   ├── __init__.py
│   ├── projects.py     # Project-related tools
│   ├── tasks.py        # Task-related tools
│   └── reports.py      # Reporting tools
├── models/
│   ├── __init__.py
│   ├── project.py      # Project data model
│   └── task.py         # Task data model
├── storage/
│   ├── __init__.py
│   └── database.py     # Shared database connection
└── config.py           # Configuration

Step 1: Define Shared State

Create a shared state manager that all tools can access. This is the key to multi-tool servers:

# storage/database.py
"""
Shared database connection for all tools.
"""
import sqlite3
from contextlib import contextmanager
from pathlib import Path
from typing import Optional
import threading


class Database:
    """Thread-safe SQLite database manager."""
    
    _instance: Optional["Database"] = None
    _lock = threading.Lock()
    
    def __new__(cls, db_path: str = "projects.db"):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
                    cls._instance._init_db(db_path)
        return cls._instance
    
    def _init_db(self, db_path: str):
        self.db_path = Path(db_path)
        self._local = threading.local()
        self._setup_tables()
    
    @property
    def conn(self) -> sqlite3.Connection:
        if not hasattr(self._local, "conn"):
            self._local.conn = sqlite3.connect(
                self.db_path,
                check_same_thread=False
            )
            self._local.conn.row_factory = sqlite3.Row
        return self._local.conn
    
    def _setup_tables(self):
        with self.conn:
            self.conn.executescript("""
                CREATE TABLE IF NOT EXISTS projects (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    name TEXT NOT NULL,
                    description TEXT,
                    status TEXT DEFAULT 'active',
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
                );
                
                CREATE TABLE IF NOT EXISTS tasks (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    project_id INTEGER NOT NULL,
                    title TEXT NOT NULL,
                    description TEXT,
                    status TEXT DEFAULT 'todo',
                    priority TEXT DEFAULT 'medium',
                    due_date DATE,
                    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
                    FOREIGN KEY (project_id) REFERENCES projects(id)
                );
            """)
    
    @contextmanager
    def transaction(self):
        try:
            yield self.conn
            self.conn.commit()
        except Exception:
            self.conn.rollback()
            raise


# Singleton instance
db = Database()

Step 2: Create Tool Modules

Organize tools into logical modules. Each module focuses on one resource type:

# tools/projects.py
"""
Project management tools.
"""
from typing import Optional
from storage.database import db


def create_project(name: str, description: Optional[str] = None) -> dict:
    """
    Create a new project.
    
    Args:
        name: The project name.
        description: Optional project description.
    
    Returns:
        The created project with its ID.
    """
    with db.transaction() as conn:
        cursor = conn.execute(
            "INSERT INTO projects (name, description) VALUES (?, ?)",
            (name, description)
        )
        project_id = cursor.lastrowid
    
    return {
        "id": project_id,
        "name": name,
        "description": description,
        "status": "active",
        "message": f"Project '{name}' created successfully"
    }


def list_projects(status: Optional[str] = None) -> list:
    """
    List all projects, optionally filtered by status.
    
    Args:
        status: Filter by status ('active', 'completed', 'archived').
    
    Returns:
        List of projects.
    """
    if status:
        rows = db.conn.execute(
            "SELECT * FROM projects WHERE status = ? ORDER BY created_at DESC",
            (status,)
        ).fetchall()
    else:
        rows = db.conn.execute(
            "SELECT * FROM projects ORDER BY created_at DESC"
        ).fetchall()
    
    return [dict(row) for row in rows]


def get_project(project_id: int) -> dict:
    """
    Get a project by ID with its tasks.
    
    Args:
        project_id: The project ID.
    
    Returns:
        Project details including task summary.
    """
    project = db.conn.execute(
        "SELECT * FROM projects WHERE id = ?",
        (project_id,)
    ).fetchone()
    
    if not project:
        return {"error": f"Project {project_id} not found"}
    
    # Get task counts
    task_stats = db.conn.execute("""
        SELECT 
            status,
            COUNT(*) as count
        FROM tasks 
        WHERE project_id = ?
        GROUP BY status
    """, (project_id,)).fetchall()
    
    result = dict(project)
    result["tasks"] = {row["status"]: row["count"] for row in task_stats}
    return result


def update_project_status(project_id: int, status: str) -> dict:
    """
    Update a project's status.
    
    Args:
        project_id: The project ID.
        status: New status ('active', 'completed', 'archived').
    
    Returns:
        Updated project info.
    """
    valid_statuses = ["active", "completed", "archived"]
    if status not in valid_statuses:
        return {"error": f"Invalid status. Must be one of: {valid_statuses}"}
    
    with db.transaction() as conn:
        conn.execute(
            "UPDATE projects SET status = ? WHERE id = ?",
            (status, project_id)
        )
    
    return get_project(project_id)

Now create the tasks module:

# tools/tasks.py
"""
Task management tools.
"""
from typing import Optional
from storage.database import db


def create_task(
    project_id: int,
    title: str,
    description: Optional[str] = None,
    priority: str = "medium",
    due_date: Optional[str] = None
) -> dict:
    """
    Create a new task in a project.
    
    Args:
        project_id: The project to add the task to.
        title: Task title.
        description: Optional task description.
        priority: Priority level ('low', 'medium', 'high', 'urgent').
        due_date: Optional due date (YYYY-MM-DD format).
    
    Returns:
        The created task.
    """
    # Verify project exists
    project = db.conn.execute(
        "SELECT id FROM projects WHERE id = ?",
        (project_id,)
    ).fetchone()
    
    if not project:
        return {"error": f"Project {project_id} not found"}
    
    with db.transaction() as conn:
        cursor = conn.execute("""
            INSERT INTO tasks (project_id, title, description, priority, due_date)
            VALUES (?, ?, ?, ?, ?)
        """, (project_id, title, description, priority, due_date))
        task_id = cursor.lastrowid
    
    return {
        "id": task_id,
        "project_id": project_id,
        "title": title,
        "description": description,
        "priority": priority,
        "due_date": due_date,
        "status": "todo",
        "message": f"Task '{title}' created"
    }


def list_tasks(
    project_id: Optional[int] = None,
    status: Optional[str] = None,
    priority: Optional[str] = None
) -> list:
    """
    List tasks with optional filters.
    
    Args:
        project_id: Filter by project.
        status: Filter by status ('todo', 'in_progress', 'done').
        priority: Filter by priority.
    
    Returns:
        List of tasks.
    """
    query = "SELECT * FROM tasks WHERE 1=1"
    params = []
    
    if project_id:
        query += " AND project_id = ?"
        params.append(project_id)
    if status:
        query += " AND status = ?"
        params.append(status)
    if priority:
        query += " AND priority = ?"
        params.append(priority)
    
    query += " ORDER BY due_date ASC NULLS LAST, priority DESC"
    
    rows = db.conn.execute(query, params).fetchall()
    return [dict(row) for row in rows]


def update_task_status(task_id: int, status: str) -> dict:
    """
    Update a task's status.
    
    Args:
        task_id: The task ID.
        status: New status ('todo', 'in_progress', 'done').
    
    Returns:
        Updated task info.
    """
    valid = ["todo", "in_progress", "done"]
    if status not in valid:
        return {"error": f"Invalid status. Must be one of: {valid}"}
    
    with db.transaction() as conn:
        conn.execute(
            "UPDATE tasks SET status = ? WHERE id = ?",
            (status, task_id)
        )
    
    task = db.conn.execute(
        "SELECT * FROM tasks WHERE id = ?",
        (task_id,)
    ).fetchone()
    
    return dict(task) if task else {"error": "Task not found"}

Step 3: Wire Up the Server

Now create the main server file that registers all tools:

# server.py
"""
Project Management MCP Server
A multi-tool server for managing projects and tasks.
"""
from fastmcp import FastMCP
from tools import projects, tasks

# Create the server
mcp = FastMCP(
    "Project Manager",
    description="Manage projects and tasks"
)

# ============== Project Tools ==============

@mcp.tool()
def create_project(name: str, description: str = None) -> str:
    """Create a new project."""
    result = projects.create_project(name, description)
    return format_result(result)


@mcp.tool()
def list_projects(status: str = None) -> str:
    """List all projects. Optionally filter by status (active/completed/archived)."""
    result = projects.list_projects(status)
    return format_result(result)


@mcp.tool()
def get_project(project_id: int) -> str:
    """Get project details including task summary."""
    result = projects.get_project(project_id)
    return format_result(result)


@mcp.tool()
def update_project_status(project_id: int, status: str) -> str:
    """Update project status to active, completed, or archived."""
    result = projects.update_project_status(project_id, status)
    return format_result(result)


# ============== Task Tools ==============

@mcp.tool()
def create_task(
    project_id: int,
    title: str,
    description: str = None,
    priority: str = "medium",
    due_date: str = None
) -> str:
    """Create a task in a project. Priority: low/medium/high/urgent."""
    result = tasks.create_task(
        project_id, title, description, priority, due_date
    )
    return format_result(result)


@mcp.tool()
def list_tasks(
    project_id: int = None,
    status: str = None,
    priority: str = None
) -> str:
    """List tasks. Filter by project, status (todo/in_progress/done), or priority."""
    result = tasks.list_tasks(project_id, status, priority)
    return format_result(result)


@mcp.tool()
def update_task_status(task_id: int, status: str) -> str:
    """Update task status to todo, in_progress, or done."""
    result = tasks.update_task_status(task_id, status)
    return format_result(result)


# ============== Helpers ==============

def format_result(result) -> str:
    """Format result for AI consumption."""
    import json
    if isinstance(result, dict) and "error" in result:
        return f"Error: {result['error']}"
    return json.dumps(result, indent=2, default=str)


# ============== Resources ==============

@mcp.resource("projects://schema")
def get_schema() -> str:
    """Database schema documentation."""
    return """
Project Management Schema:

PROJECTS:
- id: unique identifier
- name: project name (required)
- description: project description
- status: active | completed | archived
- created_at: timestamp

TASKS:
- id: unique identifier  
- project_id: foreign key to projects
- title: task title (required)
- description: task description
- status: todo | in_progress | done
- priority: low | medium | high | urgent
- due_date: YYYY-MM-DD format
- created_at: timestamp
"""


if __name__ == "__main__":
    mcp.run()

Step 4: Add Reporting Tools

Multi-tool servers shine when tools build on each other. Add reporting tools that aggregate data across projects:

# tools/reports.py
"""
Reporting tools that aggregate across projects and tasks.
"""
from storage.database import db


def get_dashboard() -> dict:
    """
    Get a dashboard summary of all projects and tasks.
    
    Returns:
        Summary statistics and recent activity.
    """
    # Project stats
    project_stats = db.conn.execute("""
        SELECT 
            status,
            COUNT(*) as count
        FROM projects
        GROUP BY status
    """).fetchall()
    
    # Task stats
    task_stats = db.conn.execute("""
        SELECT 
            status,
            COUNT(*) as count
        FROM tasks
        GROUP BY status
    """).fetchall()
    
    # Overdue tasks
    overdue = db.conn.execute("""
        SELECT COUNT(*) as count
        FROM tasks
        WHERE due_date < date('now')
        AND status != 'done'
    """).fetchone()
    
    # Recent tasks
    recent = db.conn.execute("""
        SELECT t.*, p.name as project_name
        FROM tasks t
        JOIN projects p ON t.project_id = p.id
        ORDER BY t.created_at DESC
        LIMIT 5
    """).fetchall()
    
    return {
        "projects": {row["status"]: row["count"] for row in project_stats},
        "tasks": {row["status"]: row["count"] for row in task_stats},
        "overdue_tasks": overdue["count"],
        "recent_tasks": [dict(row) for row in recent]
    }


def get_project_report(project_id: int) -> dict:
    """
    Generate a detailed report for a project.
    """
    # ... implementation
    pass

Best Practices

1. Consistent Naming

Use a consistent naming convention for related tools:

  • create_project, create_task — action + noun
  • list_projects, list_tasks — consistent pluralization
  • get_project, get_task — singular for single-item retrieval

2. Descriptive Docstrings

AI models read your docstrings to understand tools. Be specific:

# ❌ Bad
def update_status(id: int, status: str):
    """Update status."""
    
# ✅ Good  
def update_task_status(task_id: int, status: str):
    """
    Update a task's status.
    
    Args:
        task_id: The task ID to update.
        status: New status - must be 'todo', 'in_progress', or 'done'.
    
    Returns:
        Updated task object or error message.
    """

3. Return Actionable Errors

When something goes wrong, tell the AI how to fix it:

# ❌ Bad
return {"error": "Invalid"}

# ✅ Good
return {
    "error": "Invalid status 'pending'",
    "valid_values": ["todo", "in_progress", "done"],
    "hint": "Use update_task_status(task_id=5, status='in_progress')"
}

Summary

Multi-tool MCP servers are the backbone of useful AI integrations. By organizing tools logically, sharing state appropriately, and following consistent patterns, you create tool suites that AI can use effectively.

The project management server we built demonstrates these patterns: shared database state, modular tool organization, cross-cutting reporting tools, and resources for documentation. Use this as a template for your own multi-tool servers.

Questions? Reach out on Twitter or email kai@kaigritun.com.

Get updates in your inbox

Tutorials, updates, and best practices for Model Context Protocol.

No spam. Unsubscribe anytime.