MCP Server Development — Building Custom Agentic Tools
Overview
When the user asks for "agentic tools," "MCP tools," "automation tools," or "custom MCP servers," their default preference is to BUILD custom Python MCP servers from scratch on their laptop, NOT to list or recommend existing third-party MCP servers. The deliverable is a working artifact backed by real code — not a catalog of what exists.
This skill covers the architecture, patterns, and pitfalls for building standalone Python MCP servers that:
- Run 100% locally with SQLite persistence
- Expose 15–25+ tools per server via the MCP protocol
- Integrate with Hermes Agent as stdio child processes
- Store all data in
~/.hermes/mcp-data/ - Ship as independently runnable Python scripts
When to Use
- User says "I want agentic tools" — BUILD, don't SUGGEST
- User asks for MCP servers for real estate, social media, marketing, daily life — BUILD custom local ones
- User says "custom tools that run locally on my laptop with MCP built in"
- User asks for automation tools across any domain (finance, content, CRM, scraping)
- You need to add a new capability to Hermes that doesn't exist as an MCP server
Don't use for:
- Configuring existing MCP servers in Hermes (use
hermes-agentskill'sreferences/native-mcp.md) - Ad-hoc one-off tool calls (use
mcporterskill) - System tool design (shell scripts, desktop automations without MCP)
Architecture
Project Structure
project-root/
├── pyproject.toml
├── README.md
└── src/
├── common/
│ ├── __init__.py
│ └── db.py # Shared SQLite utilities
└── servers/
├── __init__.py
├── domain_one.py # Standalone MCP server — 20+ tools
├── domain_two.py # Standalone MCP server — 20+ tools
└── domain_three.py # Standalone MCP server — 20+ tools
Each Server File Is Independently Runnable
Each servers/*.py file contains a complete MCP server that can be run standalone:
python src/servers/my_server.py
And registered in Hermes config as its own stdio subprocess.
Shared Database Layer
All servers share a lightweight SQLite wrapper. Init at module level (outside async). Use sqlite3.Row as row_factory, enable WAL mode, create schema via executescript().
MCP Server Structure
Required Pattern (MCP SDK v1.x)
import asyncio
from mcp.server import Server
from mcp.server.models import InitializationOptions
from mcp.types import ServerCapabilities, ToolsCapability
import mcp.server.stdio
import mcp.types as types
# MUST: sys.path trick for standalone script imports
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
server = Server("my-server-name")
conn = init_db("my_server", SCHEMA) # MODULE level, not async
@server.list_tools()
async def list_tools() -> list[types.Tool]:
return [
types.Tool(name="my_tool", description="...",
inputSchema={"type": "object", "properties": {...}, "required": [...]}),
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> list[types.TextContent]:
if name == "my_tool":
return [types.TextContent(type="text", text="result")]
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="my-server-name",
server_version="1.0.0",
capabilities=ServerCapabilities(tools=ToolsCapability()), # REQUIRED
))
if __name__ == "__main__":
asyncio.run(main())
🔴 Critical: InitializationOptions Capabilities
InitializationOptions requires the capabilities field. Without it, the server crashes with pydantic.ValidationError. Always include capabilities=ServerCapabilities(tools=ToolsCapability()).
🔴 Critical: Import Strategy for Standalone Scripts
Standalone runnable scripts CANNOT use relative imports (from ..common.db import ... fails with ImportError: attempted relative import beyond top-level package). Use the sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) hack before local imports.
Tool Handler Pattern
Return list[types.TextContent] — each with type="text" and text field. Use if/elif/else dispatch with raise ValueError(f"Unknown tool: {name}").
Hermes Config Wiring
mcp_servers:
my-server:
command: "python"
args: ["C:/absolute/path/to/src/servers/my_server.py"]
timeout: 30
On Windows, use absolute paths. On restart, Hermes spawns the subprocess and all tools appear with the mcp_my_server_ prefix.
Tool Design Guidelines
- Target 15–25 tools per server
- Use
verb_nounnaming:task_add,note_search,campaign_create - Write LLM-facing descriptions (80-200 chars)
- Include
"description"on every property in inputSchema - Use enums where possible
- Return structured text with emoji prefixes (✅ ❌ 📋 📊 ℹ️)
- Include primary result in the first line
Database Schema Design
- Each server gets its own database at
~/.hermes/mcp-data/{name}.db - Create all tables in a single
SCHEMAconstant string - Include
created_atandupdated_attimestamps - Use
TEXTfor dates (ISO 8601) - Use foreign keys for relational data
- Seed default data via module-level function called at import time
Verification Checklist
- Server imports cleanly via
from servers.my_server import server -
list_tools()returns all expected tools with valid schemas -
capabilities=ServerCapabilities(tools=ToolsCapability())inInitializationOptions -
sys.path.insert(0, ...)present for standalone imports - Database init at module level (not inside async main)
- At least one tool call works:
handle_call_tool("tool", {"param": "val"}) - All returns use
list[types.TextContent] - Fallback
raise ValueError(f"Unknown tool: {name}")present - Hermes config includes the server under
mcp_servers: - Server startup does not crash (test with timeout)
Common Pitfalls
Missing
capabilitiesinInitializationOptions— crashes withpydantic.ValidationError. Always includecapabilities=ServerCapabilities(tools=ToolsCapability()).Relative imports in standalone scripts —
from ..common.db import ...fails because standalone scripts aren't run as a package. Use thesys.path.insert(0, ...)hack before local imports.Database init inside
async def main()— decorators registered during import may accessconnbeforemain()runs. Init at module level.call_toolhandler not async — MCP SDK expects async handlers. Mark withasync def.Returning raw string instead of
list[types.TextContent]— MCP protocol expects content array.Forgetting user preference to BUILD — When user asks for "tools" or "MCP tools," default to building custom local Python servers, not cataloging existing ones. "I want agentic tools" means "write code."
Subprocess pipe testing on Windows —
asyncio.create_subprocess_exec+ stdin/stdout piping is unreliable for MCP protocol testing. Test imports +list_tools()+handle_call_tool()via direct Python calls.
One-Shot Recipes
Create a New MCP Server from Scratch
- Create
src/common/db.pywith shared database utilities - Create
src/servers/{domain}.pywith the full pattern (imports, schema, server init, list_tools, call_tool, main) - Add to Hermes config under
mcp_servers: - Verify: import + list_tools + tool call
Verify All Servers Work
import sys; sys.path.insert(0, 'src')
from servers.my_server import server, list_tools, handle_call_tool
import asyncio
async def verify():
tools = await list_tools()
print(f"{len(tools)} tools")
result = await handle_call_tool("my_tool", {"key": "val"})
print(result[0].text[:100])
asyncio.run(verify())
Scale: Add a Database Table
Add a new CREATE TABLE to the SCHEMA constant, add a new tool handler branch, add a new Tool definition to list_tools(). SQLite handles schema addition gracefully.