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#
Require McpServer and create an instance on any path:
require "tina4"โmcp = Tina4::McpServer.new("/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.rb before run:
mcp.register_routes3. Registering Tools with mcp_tool#
The Tina4.mcp_tool method turns a block into an MCP tool. Parameter metadata becomes the input schema:
require "tina4"โmcp = Tina4::McpServer.new("/crm/mcp", name: "CRM Tools")โTina4.mcp_tool("lookup_customer", description: "Find a customer by email", server: mcp, params: [{ name: "email", type: "string" }]) do |args| db.fetch_one("SELECT * FROM customers WHERE email = ?", [args["email"]])endโTina4.mcp_tool("recent_orders", description: "Get recent orders for a customer", server: mcp, params: [ { name: "customer_id", type: "integer" }, { name: "limit", type: "integer", default: 10 } ]) do |args| db.fetch( "SELECT * FROM orders WHERE customer_id = ? ORDER BY created_at DESC", [args["customer_id"]], limit: args["limit"] || 10 ).to_aendThe 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
descriptionargument
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:
Tina4.mcp_resource("crm://product-catalog", description: "All active products", server: mcp) do db.fetch("SELECT id, name, price, category FROM products WHERE active = 1").to_aendโTina4.mcp_resource("crm://tax-rates", description: "Current tax rates by region", server: mcp) do db.fetch("SELECT region, rate FROM tax_rates").to_aendResources 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:
require "tina4"โmcp = Tina4::McpServer.new("/accounting/mcp", name: "Accounting Tools")โclass AccountingService def initialize(db) @db = db endโ def lookup(invoice_no) @db.fetch_one("SELECT * FROM invoices WHERE invoice_no = ?", [invoice_no]) endโ def balances(min_amount = 0.0) @db.fetch("SELECT * FROM invoices WHERE paid = 0 AND total >= ?", [min_amount]).to_a endโ def summary(year, month) @db.fetch_one( "SELECT SUM(total) as revenue, COUNT(*) as invoice_count " \ "FROM invoices WHERE strftime('%Y', created_at) = ? " \ "AND strftime('%m', created_at) = ?", [year.to_s, format("%02d", month)] ) endendโsvc = AccountingService.new(db)โTina4.mcp_tool("invoice_lookup", description: "Find an invoice by number", server: mcp, params: [{ name: "invoice_no", type: "string" }]) do |args| svc.lookup(args["invoice_no"])endโTina4.mcp_tool("outstanding_balances", description: "List all unpaid invoices", server: mcp, params: [{ name: "min_amount", type: "number", default: 0.0 }]) do |args| svc.balances(args["min_amount"] || 0.0)endโTina4.mcp_tool("monthly_summary", description: "Revenue summary for a month", server: mcp, params: [ { name: "year", type: "integer" }, { name: "month", type: "integer" } ]) do |args| svc.summary(args["year"], args["month"])end6. Securing MCP Endpoints#
By default, developer MCP servers are public. Add authentication with a path-scoped before middleware that runs ahead of the MCP routes:
mcp.register_routesโ# Require a valid bearer token on every request under the MCP pathTina4::Middleware.before("#{mcp.path}/*") do |request, response| token = request.headers["Authorization"].to_s.sub(/^Bearer /, "") unless Tina4::Auth.valid_token(token) response.json({ error: "Unauthorized" }, 401) endendOr check the bearer token inside individual tools:
Tina4.mcp_tool("sensitive_data", description: "Access restricted data", server: mcp, params: [{ name: "token", type: "string" }]) do |args| payload = Tina4::Auth.valid_token(args["token"]) unless payload next { error: "Unauthorized" } end db.fetch("SELECT * FROM sensitive_table").to_aend7. Testing MCP Tools#
Test tool functions directly, or test via the MCP protocol:
# Test the tool block directlydef test_lookup_customer result = db.fetch_one("SELECT * FROM customers WHERE email = ?", ["alice@example.com"]) assert result != nil assert_equal "alice@example.com", result["email"]endโ# Test via MCP protocoldef test_mcp_tool_call resp = JSON.parse(mcp.handle_message({ "jsonrpc" => "2.0", "id" => 1, "method" => "tools/call", "params" => { "name" => "lookup_customer", "arguments" => { "email" => "alice@example.com" } } })) assert resp.key?("result") content = resp["result"]["content"][0]["text"] assert content.downcase.include?("alice")end8. Complete Example: CRM MCP Server#
Here is a full working example -- a CRM system with customer, order, and product tools:
# app.rbrequire "tina4"โdb = Tina4::Database.new("sqlite:///crm.db")Tina4.bind_database(db) # models resolve Tina4.database; or set per-model with Model.db = dbโ# Create MCP servercrm_mcp = Tina4::McpServer.new("/crm/mcp", name: "CRM Assistant", version: "1.0.0")โ# ToolsTina4.mcp_tool("find_customer", description: "Search customers by name or email", server: crm_mcp, params: [{ name: "query", type: "string" }]) do |args| q = args["query"] db.fetch("SELECT * FROM customers WHERE name LIKE ? OR email LIKE ?", ["%#{q}%", "%#{q}%"]).to_aendโTina4.mcp_tool("customer_orders", description: "Get all orders for a customer", server: crm_mcp, params: [{ name: "customer_id", type: "integer" }]) do |args| 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"]] ).to_aendโTina4.mcp_tool("create_note", description: "Add a note to a customer record", server: crm_mcp, params: [ { name: "customer_id", type: "integer" }, { name: "note", type: "string" } ]) do |args| db.insert("customer_notes", { customer_id: args["customer_id"], note: args["note"] }) { success: true }endโ# ResourcesTina4.mcp_resource("crm://products", description: "Product catalog", server: crm_mcp) do db.fetch("SELECT * FROM products WHERE active = 1").to_aendโTina4.mcp_resource("crm://stats", description: "CRM statistics", server: crm_mcp) do customers = db.fetch_one("SELECT COUNT(*) as count FROM customers") orders = db.fetch_one("SELECT COUNT(*) as count, SUM(total) as revenue FROM orders") { customers: customers["count"], orders: orders["count"], revenue: orders["revenue"] }endโ# Register routescrm_mcp.register_routesโTina4.runConnect Claude Code to http://localhost:7147/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 -- hashes and arrays, not formatted strings. Let the AI format for the user
- Secure production endpoints -- add a path-scoped
Tina4::Middleware.beforetoken check (Section 6) for any MCP server that runs outside localhost - Test tools directly -- call the Ruby method in your test suite, not just through the MCP protocol