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:
import { McpServer } from "@tina4/core";โconst mcp = new McpServer("/api/my-tools", "My App Tools", "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 your server setup:
mcp.registerRoutes(router);3. Registering Tools with mcpTool#
The mcpTool function registers a handler as an MCP tool. Parameter metadata becomes the input schema:
import { McpServer, mcpTool, schemaFromParams } from "@tina4/core";โconst mcp = new McpServer("/crm/mcp", "CRM Tools");โmcpTool("lookup_customer", "Find a customer by email", mcp, [ { name: "email", type: "string" },])((args) => { return db.fetchOne("SELECT * FROM customers WHERE email = ?", [args.email]);});โmcpTool("recent_orders", "Get recent orders for a customer", mcp, [ { name: "customer_id", type: "integer" }, { name: "limit", type: "integer", default: 10 },])((args) => { return db.fetch( "SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC", [args.customer_id], { limit: (args.limit as number) || 10 } );});The registration extracts:
- Parameter names from the params list
- Types from the type field (
"string","integer","boolean", etc.) - Required vs optional -- parameters with defaults are optional
- Description from the description argument
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 mcpResource#
Resources are read-only data endpoints. They expose reference data that AI assistants can browse:
import { mcpResource } from "@tina4/core";โmcpResource("crm://product-catalog", "All active products", "application/json", mcp)( () => db.fetch("SELECT id, name, price, category FROM products WHERE active = 1"));โmcpResource("crm://tax-rates", "Current tax rates by region", "application/json", mcp)( () => db.fetch("SELECT region, rate FROM tax_rates"));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. Register each method as a tool:
import { McpServer, schemaFromParams } from "@tina4/core";โconst mcp = new McpServer("/accounting/mcp", "Accounting Tools");โclass AccountingService { constructor(private db: any) {}โ lookup(invoiceNo: string) { return this.db.fetchOne( "SELECT * FROM invoices WHERE invoice_no = ?", [invoiceNo] ); }โ balances(minAmount = 0.0) { return this.db.fetch( "SELECT * FROM invoices WHERE paid = 0 AND total >= ?", [minAmount] ); }โ summary(year: number, month: number) { return this.db.fetchOne( "SELECT SUM(total) as revenue, COUNT(*) as invoice_count " + "FROM invoices WHERE strftime('%Y', created_at) = ? " + "AND strftime('%m', created_at) = ?", [String(year), String(month).padStart(2, "0")] ); }}โconst svc = new AccountingService(db);โmcp.registerTool("invoice_lookup", (args) => svc.lookup(args.invoice_no as string), "Find an invoice by number", schemaFromParams([{ name: "invoice_no", type: "string" }]));โmcp.registerTool("outstanding_balances", (args) => svc.balances((args.min_amount as number) || 0), "List all unpaid invoices", schemaFromParams([{ name: "min_amount", type: "number", default: 0.0 }]));โmcp.registerTool("monthly_summary", (args) => svc.summary(args.year as number, args.month as number), "Revenue summary for a month", schemaFromParams([{ name: "year", type: "integer" }, { name: "month", type: "integer" }]));6. Securing MCP Endpoints#
By default, developer MCP servers are public. Add authentication using Tina4 middleware:
// Secure the MCP routes via middlewareimport { secured } from "@tina4/core";โ// Apply auth middleware before registering routesmcp.registerRoutes(router); // then protect the path with middlewareOr check the bearer token inside individual tools:
import { Auth } from "@tina4/core";โmcp.registerTool("sensitive_data", (args) => { const payload = Auth.validToken(args.token as string, secret); if (!payload) return { error: "Unauthorized" }; return db.fetch("SELECT * FROM sensitive_table"); }, "Access restricted data", schemaFromParams([{ name: "token", type: "string" }]));7. Testing MCP Tools#
Test tool functions directly, or test via the MCP protocol:
// Test the tool function directlyfunction testLookupCustomer() { const result = db.fetchOne("SELECT * FROM customers WHERE email = ?", ["alice@example.com"]); assert(result !== null); assert(result.email === "alice@example.com");}โ// Test via MCP protocolfunction testMcpToolCall() { const resp = JSON.parse(mcp.handleMessage({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "lookup_customer", arguments: { email: "alice@example.com" }, }, })); assert("result" in resp); const content = resp.result.content[0].text; assert(content.toLowerCase().includes("alice"));}8. Complete Example: CRM MCP Server#
Here is a full working example -- a CRM system with customer, order, and product tools:
// src/routes/mcp/setup.tsimport { McpServer, mcpTool, mcpResource, schemaFromParams } from "@tina4/core";import { initDatabase } from "@tina4/orm";โconst db = await initDatabase({ url: "sqlite:///crm.db" });โ// Create MCP serverconst crmMcp = new McpServer("/crm/mcp", "CRM Assistant", "1.0.0");โ// ToolsmcpTool("find_customer", "Search customers by name or email", crmMcp, [ { name: "query", type: "string" },])((args) => { const q = args.query as string; return db.fetch( "SELECT * FROM customers WHERE name LIKE ? OR email LIKE ?", [`%${q}%`, `%${q}%`] );});โmcpTool("customer_orders", "Get all orders for a customer", crmMcp, [ { name: "customer_id", type: "integer" },])((args) => { 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", [args.customer_id] );});โmcpTool("create_note", "Add a note to a customer record", crmMcp, [ { name: "customer_id", type: "integer" }, { name: "note", type: "string" },])((args) => { db.execute("INSERT INTO customer_notes (customer_id, note) VALUES (?, ?)", [args.customer_id, args.note]); return { success: true };});โ// ResourcesmcpResource("crm://products", "Product catalog", "application/json", crmMcp)( () => db.fetch("SELECT * FROM products WHERE active = 1"));โmcpResource("crm://stats", "CRM statistics", "application/json", crmMcp)(() => { const customers = db.fetch("SELECT COUNT(*) as count FROM customers"); const orders = db.fetch("SELECT COUNT(*) as count, SUM(total) as revenue FROM orders"); return { customers: customers[0]?.count ?? 0, orders: orders[0]?.count ?? 0, revenue: orders[0]?.revenue ?? 0, };});โ// Register routescrmMcp.registerRoutes(router);โexport { crmMcp };Connect Claude Code to http://localhost:7148/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 param metadata -- types and defaults become the schema. An AI assistant cannot call a tool correctly without knowing the parameter types
- Return structured data -- objects and arrays, not formatted strings. Let the AI format for the user
- Secure production endpoints -- use middleware for any MCP server that runs outside localhost
- Test tools directly -- call the TypeScript function in your test suite, not just through the MCP protocol