Skip to content

Building MCP Servers and Tools — Model Context Protocol Development Guide

DodaTech Updated 2026-06-22 6 min read

The Model Context Protocol (MCP) standardizes how LLMs interact with external tools and data sources — this guide covers building MCP servers that extend AI capabilities with secure, typed tool interfaces.

What You'll Learn

You'll learn the MCP protocol architecture, build a Python-based MCP server with custom tools, implement resource providers and prompts, and connect MCP to LLM clients like Claude and custom applications.

Why It Matters

Without MCP, every AI integration requires custom glue code. MCP provides a standardized protocol where one server works with any MCP-compatible client, dramatically reducing integration effort and improving security through explicit tool definitions.

Real-World Use

Doda Browser uses MCP servers to give its AI assistant access to local file search, bookmark management, and browser history — all through a unified protocol instead of separate API integrations.

MCP Architecture

flowchart LR
    A[LLM Client] --> B[MCP Client]
    B --> C[MCP Server]
    C --> D[Tools]
    C --> E[Resources]
    C --> F[Prompts]
    D --> G[File System]
    D --> H[Database]
    D --> I[External APIs]

Setting Up an MCP Server

Install the MCP SDK and create a basic server.

# Requires: pip install mcp httpx
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import mcp.server.stdio
import mcp.types as types
from typing import Any

server = Server("file-tools-server")

@server.list_tools()
async def handle_list_tools() -> list[types.Tool]:
    return [
        types.Tool(
            name="read_file",
            description="Read the contents of a file at the given path",
            inputSchema={
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Absolute path to the file]
                    }
                },
                "required": ["path"]
            }
        ),
        types.Tool(
            name="search_files",
            description="Search for files matching a pattern",
            inputSchema={
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": "Glob pattern to match"
                    },
                    "root_dir": {
                        "type": "string",
                        "description": "Root directory to search"
                    }
                },
                "required": ["pattern"]
            }
        )
    ]

@server.call_tool()
async def handle_call_tool(
    name: str, arguments: dict[str, Any] | None
) -> list[types.TextContent]:
    if not arguments:
        raise ValueError("Missing arguments")

    if name == "read_file":
        path = arguments["path"]
        try:
            with open(path, "r") as f:
                content = f.read()
            return [types.TextContent(
                type="text", text=content
            )]
        except Exception as e:
            return [types.TextContent(
                type="text",
                text=f"Error reading file: {str(e)}]
            )]

    elif name == "search_files":
        import glob as glob_mod
        pattern = arguments["pattern"]
        root = arguments.get("root_dir", ".")
        matches = glob_mod.glob(
            f"{root}/{pattern}", recursive=True
        )
        return [types.TextContent(
            type="text",
            text="\n".join(matches[:50])
        )]

    raise ValueError(f"Unknown tool: {name}")

async def main():
    async with mcp.server.stdio.stdio_server() as (read, write):
        await server.run(
            read, write,
            InitializationOptions(
                server_name="file-tools-server",
                server_version="0.1.0"
            )
        )

if __name__ == "__main__":
    import asyncio
    asyncio.run(main())

print("MCP server ready — run with: python mcp_file_server.py")

Expected output:

MCP server ready — run with: python mcp_file_server.py

Adding Resource Providers

Resources let the LLM read structured data from the server.

@server.list_resources()
async def handle_list_resources() -> list[types.Resource]:
    return [
        types.Resource(
            uri="file:///workspace/config.json",
            name="Configuration",
            description="Application configuration file",
            mimeType="application/json",
        ),
        types.Resource(
            uri="file:///workspace/README.md",
            name="README",
            description="Project README file",
            mimeType="text/markdown",
        )
    ]

@server.read_resource()
async def handle_read_resource(uri: str) -> str:
    if uri.startswith("file://"):
        path = uri.replace("file://", "")
        try:
            with open(path, "r") as f:
                return f.read()
        except FileNotFoundError:
            return json.dumps({"error": f"File not found: {path}"})

    raise ValueError(f"Unsupported resource URI: {uri}")

print("Resource providers registered")

Expected output:

Resource providers registered

Database Query Tool

Build a tool that allows safe SQL queries through MCP.

import sqlite3
import json
from typing import Any

class DatabaseMCPExtension:
    def __init__(self, db_path: str):
        self.conn = sqlite3.connect(db_path)
        self.conn.row_factory = sqlite3.Row

    def get_tools(self) -> list[types.Tool]:
        # Get table schemas for tool descriptions
        tables = self.conn.execute(
            "SELECT name FROM sqlite_master WHERE type='table'"
        ).fetchall()
        table_list = [t["name"] for t in tables]

        return [
            types.Tool(
                name="query_database",
                description=f"""Execute a SELECT query on the SQLite database.
Available tables: {', '.join(table_list)}
Only SELECT queries are allowed.
Max 100 rows returned.""",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "SELECT SQL query]
                        }
                    },
                    "required": ["query"]
                }
            ),
            types.Tool(
                name="get_table_schema",
                description="Get the schema of a table",
                inputSchema={
                    "type": "object",
                    "properties": {
                        "table_name": {
                            "type": "string",
                            "description": "Name of the table"
                        }
                    },
                    "required": ["table_name"]
                }
            )
        ]

    def execute_query(self, query: str) -> str:
        query_upper = query.strip().upper()
        if not query_upper.startswith("SELECT"):
            return json.dumps({
                "error": "Only SELECT queries are allowed"
            })

        try:
            cursor = self.conn.execute(query)
            rows = cursor.fetchmany(100)
            columns = [desc[0] for desc in cursor.description]
            result = [dict(zip(columns, row)) for row in rows]
            return json.dumps(result, indent=2)
        except Exception as e:
            return json.dumps({"error": str(e)})

# Test extension
ext = DatabaseMCPExtension(":memory:")
ext.conn.execute("CREATE TABLE users (id INT, name TEXT, email TEXT)")
ext.conn.execute(
    "INSERT INTO users VALUES (1, 'Alice', 'alice@example.com')"
)
ext.conn.execute(
    "INSERT INTO users VALUES (2, 'Bob', 'bob@example.com')"
)
ext.conn.commit()

result = ext.execute_query("SELECT * FROM users")
print("Query result:")
print(result)

Expected output:

Query result:
[
  {
    "id": 1,
    "name": "Alice",
    "email": "alice"@example".com]
  },
  {
    "id": 2,
    "name": "Bob",
    "email": "bob"@example".com"
  }
]

Error Handling with Structured Responses

Return typed error information instead of raw exceptions.

from typing import Union
from dataclasses import dataclass

@dataclass
class ToolResult:
    success: bool
    data: Any = None
    error_code: str = None
    error_message: str = None

    def to_mcp_content(self) -> list[types.TextContent]:
        if self.success:
            return [types.TextContent(
                type="text",
                text=json.dumps(self.data, indent=2)
                    if not isinstance(self.data, str)
                    else self.data
            )]

        return [types.TextContent(
            type="text",
            text=json.dumps({
                "error": True,
                "code": self.error_code,
                "message": self.error_message
            })
        )]

def safe_tool_call(tool_func, **kwargs) -> list[types.TextContent]:
    try:
        result = tool_func(**kwargs)
        return ToolResult(success=True, data=result).to_mcp_content()
    except PermissionError:
        return ToolResult(
            success=False,
            error_code="PERMISSION_DENIED",
            error_message=f"No permission to access resource"
        ).to_mcp_content()
    except FileNotFoundError as e:
        return ToolResult(
            success=False,
            error_code="NOT_FOUND",
            error_message=str(e)
        ).to_mcp_content()
    except Exception as e:
        return ToolResult(
            success=False,
            error_code="INTERNAL_ERROR",
            error_message=f"Unexpected error: {str(e)}"
        ).to_mcp_content()

# Test
success_result = safe_tool_call(
    lambda path: f"Content of {path}",
    path="/safe/path.txt"
)
print("Success result:", success_result[0].text[:100])

error_result = safe_tool_call(
    lambda path: (_ for _ in ()).throw(
        PermissionError("Access denied")
    ),
    path="/etc/shadow"
)
print("Error result:", error_result[0].text[:100])

Expected output:

Success result: "Content of /safe/path.txt"
Error result: {"error": true, "code": "PERMISSION_DENIED", "message": "No permission to access resource"}

Common Errors

Error Cause Fix
Tool call returns empty response Tool function did not return TextContent Always wrap return values in types.TextContent or types.EmbeddedResource
MCP client cannot connect to server Server not started or wrong transport Run the server first; check transport type (stdio vs SSE)
LLM ignores available tools Tool names and descriptions unclear Use descriptive names and include usage examples in the description field
File tool accesses restricted paths No path validation in tool implementation Validate all paths against an allowed directory whitelist
Database tool crashes on complex queries SQL Injection in read-only mode Use parameterized queries and strip INSERT/UPDATE/DELETE

Practice Questions

  1. What is the Model Context Protocol and what problem does it solve? MCP standardizes how LLMs interact with external tools and data, replacing custom integrations with a universal protocol that any MCP-compatible client can use.

  2. How do MCP tools differ from MCP resources? Tools are actions the LLM can invoke (with parameters); resources are data the LLM can read (identified by URI).

  3. Why should MCP tool inputs use JSON Schema? JSON Schema provides type safety, validation, and documentation that the LLM can use to construct correct tool calls.

  4. How does MCP handle security for file system access? The server explicitly defines which paths and operations are allowed; the LLM can only call registered tools with validated inputs.

  5. Challenge: Build an MCP server that wraps three external APIs (GitHub, Slack, and a weather API) as individual tools, implements Rate Limiting per tool, and returns structured error responses when API limits are exceeded.

Mini Project

Build a development toolkit MCP server with tools for file search with regex, git status and diff inspection, code formatting with black (preview only), and running tests with output capture. Connect it to an MCP-compatible client (like Claude Desktop or a custom chatbot) and demonstrate each tool working in a conversation.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro