Chapter 28: Building Custom MCP Servers#
1. Beyond Dev Tools#
Chapter 27 covered the built-in MCP server that ships with Tina4. It exposes framework internals for AI-assisted development. This chapter goes further: you build your own MCP servers that expose your application's business logic.
A CRM system exposes customer lookup. An accounting system exposes invoice queries. A warehouse system exposes inventory checks. Any domain logic that an AI assistant should access becomes an MCP tool.
2. Creating an MCP Server#
Import McpServer and create an instance on any path:
from tina4_python.mcp import McpServerโmcp = McpServer("/api/my-tools", name="My App Tools", version="1.0.0")The server registers HTTP endpoints at:
POST /api/my-tools/message-- JSON-RPC message handlerGET /api/my-tools/sse-- SSE endpoint for client discovery
Register it with the router in app.py before run():
from tina4_python.core.router import get, postmcp.register_routes(__import__("tina4_python.core.router", fromlist=["router"]))3. Registering Tools with @mcp_tool#
The @mcp_tool decorator turns a function into an MCP tool. Type hints become the input schema automatically:
from tina4_python.mcp import McpServer, mcp_toolโmcp = McpServer("/crm/mcp", name="CRM Tools")โ@mcp_tool("lookup_customer", description="Find a customer by email", server=mcp)def lookup_customer(email: str): """Search the customer database by email address.""" return db.fetch_one("SELECT * FROM customers WHERE email = ?", [email])โ@mcp_tool("recent_orders", description="Get recent orders for a customer", server=mcp)def recent_orders(customer_id: int, limit: int = 10): """Fetch the most recent orders for a customer.""" result = db.fetch( "SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC", [customer_id], limit=limit ) return result.to_array()The decorator extracts:
- Parameter names from the function signature
- Types from type hints (
str->"string",int->"integer", etc.) - Required vs optional -- parameters with defaults are optional
- Description from the
descriptionargument or the docstring
An AI assistant sees these tools and their schemas:
{ "name": "lookup_customer", "description": "Find a customer by email", "inputSchema": { "type": "object", "properties": { "email": {"type": "string"} }, "required": ["email"] }}4. Registering Resources with @mcp_resource#
Resources are read-only data endpoints. They expose reference data that AI assistants can browse:
from tina4_python.mcp import mcp_resourceโ@mcp_resource("crm://product-catalog", description="All active products", server=mcp)def product_catalog(): return db.fetch("SELECT id, name, price, category FROM products WHERE active = 1").to_array()โ@mcp_resource("crm://tax-rates", description="Current tax rates by region", server=mcp)def tax_rates(): return db.fetch("SELECT region, rate FROM tax_rates").to_array()Resources are accessed via resources/list and resources/read in the MCP protocol.
5. Class-Based MCP Services#
Group related tools into a service class. Each method with @mcp_tool becomes a tool:
from tina4_python.mcp import McpServer, mcp_toolโmcp = McpServer("/accounting/mcp", name="Accounting Tools")โclass AccountingService: def __init__(self, db): self.db = dbโ @mcp_tool("invoice_lookup", description="Find an invoice by number", server=mcp) def lookup(self, invoice_no: str): return self.db.fetch_one( "SELECT * FROM invoices WHERE invoice_no = ?", [invoice_no] )โ @mcp_tool("outstanding_balances", description="List all unpaid invoices", server=mcp) def balances(self, min_amount: float = 0.0): return self.db.fetch( "SELECT * FROM invoices WHERE paid = 0 AND total >= ?", [min_amount] ).to_array()โ @mcp_tool("monthly_summary", description="Revenue summary for a month", server=mcp) def summary(self, year: int, month: int): return self.db.fetch_one( "SELECT SUM(total) as revenue, COUNT(*) as invoice_count " "FROM invoices WHERE strftime('%Y', created_at) = ? " "AND strftime('%m', created_at) = ?", [str(year), f"{month:02d}"] )โ# Create the service instance - methods are already registered via decoratorsaccounting = AccountingService(db)6. Securing MCP Endpoints#
By default, developer MCP servers are public. Add authentication using the standard Tina4 middleware:
from tina4_python.core.router import middleware, securedโ# Secure the entire MCP path@secured()@middleware(AuthMiddleware)def register_mcp(): mcp.register_routes(router)Or check the bearer token inside individual tools:
@mcp_tool("sensitive_data", description="Access restricted data", server=mcp)def sensitive_data(token: str): payload = Auth.valid_token_static(token) if not payload: return {"error": "Unauthorized"} return db.fetch("SELECT * FROM sensitive_table").to_array()7. Testing MCP Tools#
Use TestClient to test MCP endpoints without starting a server, or test tool functions directly:
# Test the tool function directlydef test_lookup_customer(): result = lookup_customer("alice@example.com") assert result is not None assert result["email"] == "alice@example.com"โ# Test via MCP protocoldef test_mcp_tool_call(): import json resp = json.loads(mcp.handle_message({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "lookup_customer", "arguments": {"email": "alice@example.com"} } })) assert "result" in resp content = resp["result"]["content"][0]["text"] assert "alice" in content.lower()8. Complete Example: CRM MCP Server#
Here is a full working example -- a CRM system with customer, order, and product tools:
# app.pyfrom tina4_python.core import runfrom tina4_python.orm import bind_databasefrom tina4_python.database import Databasefrom tina4_python.mcp import McpServer, mcp_tool, mcp_resourceโdb = Database("sqlite:///crm.db")bind_database(db)โ# Create MCP servercrm_mcp = McpServer("/crm/mcp", name="CRM Assistant", version="1.0.0")โ# Tools@mcp_tool("find_customer", description="Search customers by name or email", server=crm_mcp)def find_customer(query: str): return db.fetch( "SELECT * FROM customers WHERE name LIKE ? OR email LIKE ?", [f"%{query}%", f"%{query}%"] ).to_array()โ@mcp_tool("customer_orders", description="Get all orders for a customer", server=crm_mcp)def customer_orders(customer_id: int): return db.fetch( "SELECT o.*, GROUP_CONCAT(oi.product_name) as items " "FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id " "WHERE o.customer_id = ? GROUP BY o.id ORDER BY o.created_at DESC", [customer_id] ).to_array()โ@mcp_tool("create_note", description="Add a note to a customer record", server=crm_mcp)def create_note(customer_id: int, note: str): db.insert("customer_notes", {"customer_id": customer_id, "note": note}) return {"success": True}โ# Resources@mcp_resource("crm://products", description="Product catalog", server=crm_mcp)def products(): return db.fetch("SELECT * FROM products WHERE active = 1").to_array()โ@mcp_resource("crm://stats", description="CRM statistics", server=crm_mcp)def stats(): customers = db.fetch_one("SELECT COUNT(*) as count FROM customers") orders = db.fetch_one("SELECT COUNT(*) as count, SUM(total) as revenue FROM orders") return { "customers": customers["count"], "orders": orders["count"], "revenue": orders["revenue"], }โ# Register routesimport tina4_python.core.router as routercrm_mcp.register_routes(router)โif __name__ == "__main__": run()Connect Claude Code to http://localhost:7146/crm/mcp/sse and ask:
"Find all customers named Smith and show their recent orders"
The AI calls find_customer with query: "Smith", then customer_orders for each result. No custom API needed. The MCP protocol handles it.
9. Best Practices#
- One server per domain -- CRM tools on
/crm/mcp, accounting on/accounting/mcp - Keep tools focused -- one query per tool, not a Swiss-army-knife tool
- Use type hints -- they become the schema. An AI assistant cannot call a tool correctly without knowing the parameter types
- Return structured data -- dicts and lists, not formatted strings. Let the AI format for the user
- Secure production endpoints -- use
@secured()or middleware for any MCP server that runs outside localhost - Test tools directly -- call the Python function in your test suite, not just through the MCP protocol