Building MCP Servers and Tools — Model Context Protocol Development Guide
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
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.
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).
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.
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.
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