Home Insights How We Built an MCP Server in Ruby on Rails
Engineering

How We Built an MCP Server in Ruby on Rails

MT
Michael Thomas Co-founder & CEO, TailyX AI July 2026

Most MCP write-ups are a tutorial: install this gem, copy this handler, ship a toy server against an in-memory dataset. This isn't that. TailyX runs a production MCP server in front of a real Rails 8 application on Heroku, backed by Postgres and Redis, serving live lead-qualification data to AI agents right now. Here's what we actually decided, and where those decisions cost us.

Engineering TailyX — Part 1 of 4

Next: AI Discovery Files, Explained

Why We Built One

TailyX's core product is a lead-qualification widget: visitors interact with structured intent buttons and a deterministic scoring engine — we call it the FlowResolver — rather than a freeform chat box. That design choice, made for reasons that had nothing to do with agents, turned out to be exactly what MCP wants. Deterministic, schema-shaped outputs are what an agent can reason over reliably. Conversational transcripts are not.

The decision that made our qualification results explainable to a sales team also made them consumable by an agent. That wasn't foresight — auditability and machine-readability are the same requirement wearing different clothes.

Building the MCP server was mostly exposing an interface we'd already built for humans to a new class of caller — not a bolt-on integration project.

Architecture

Agent client (ChatGPT / Claude / custom)
        │
        ▼
  MCP transport layer  (JSON-RPC over HTTP)
        │
        ▼
  MCP::Server (Rails service object, not a parallel app)
        │
        ├─▶ lookup_widget_by_domain   → resolves tenant from a domain
        ├─▶ get_agent_schema          → returns the qualification schema for that tenant
        ├─▶ submit_agent_lead         → runs the FlowResolver scoring engine
        └─▶ get_lead_status           → polls result state
        │
        ▼
  Same Postgres-backed models the web widget uses
        │
        ▼
  Sidekiq jobs for anything async (routing, notification, CRM sync)

The deliberate constraint: the MCP layer is a thin adapter, not a second brain. Every tool call hits the same ActiveRecord models and the same FlowResolver logic the browser widget uses. No separate qualification logic exists for agents — that's the line between "supporting MCP" and "bolting MCP onto a demo."

Tool Design

Four tools, each doing one narrow job:

  • lookup_widget_by_domain — resolves tenant from a domain, the natural entry point since an agent knows the business's domain, not an internal tenant ID.
  • get_agent_schema — returns the qualification question set and expected answer shape before the agent submits anything.
  • submit_agent_lead — the scoring call: structured answers in, FlowResolver result out.
  • get_lead_status — polling for async side effects, since scoring can trigger routing or CRM sync that doesn't complete synchronously.

The first version of this interface didn't look like four narrow tools. An earlier draft accepted a single broader payload — resolve the tenant, validate the schema, and score the lead in one call, on the theory that fewer round trips meant a simpler integration. In practice that tool became harder to validate (one failure could originate from three different causes), harder to document (the parameter object had optional fields whose meaning depended on which "mode" you were in), and easier for a client to call with an incomplete or malformed payload that partially matched what it expected. Splitting it into four narrowly scoped tools — each with one job and one failure mode — made both testing and integration simpler, at the cost of an extra round trip per lead. That trade-off held up; the assumption that fewer calls beats clearer calls didn't.

The sequence a client actually walks through, end to end:

1. lookup_widget_by_domain(domain: "example.com")
     → { tenant_id: "...", widget_config: {...} }

2. get_agent_schema(tenant_id: "...")
     → { fields: [...], required: [...] }

3. submit_agent_lead(tenant_id: "...", answers: {...})
     → { qualification_result: "...", lead_id: "..." }

4. get_lead_status(lead_id: "...")
     → { status: "routed" | "pending" | "rejected" }

Authentication and Discovery

Discovery ("how does an agent learn my server exists") and authentication ("should this caller be allowed to call this tool") are separate problems we were careful not to conflate. Discovery is largely solved by publishing a well-formed schema and pointing to it from llms.txt. Authentication is a standard API auth problem wearing an MCP costume.

Standard token-based auth sits on the transport layer. Tenant resolution happens as the first step inside the tool call, not baked into the transport. No state-changing tool trusts a discovery-layer claim without re-validating tenant scope server-side. The failure mode we were most paranoid about wasn't "agent can't find our server" — it was "agent finds our server and submits a lead against a tenant it shouldn't touch."

Testing

MCP tooling is immature relative to standard API testing in mid-2026. The test suite runs three layers:

  1. Unit tests on tool handlers — same RSpec discipline as any Rails controller action.
  2. Schema-drift tests — assert get_agent_schema's output still matches what submit_agent_lead accepts. These two silently drifting apart is the single most common MCP bug class we've hit.
  3. Live client tests — a real agent client, not a mock, running the full discovery → schema → submit → status loop before any deploy touching the MCP layer. Mocked clients pass tests real ones fail.

Production Deployment

Nothing exotic, which is the point: the MCP server ships as part of the same Heroku release as the rest of the app. No separate service, no separate pipeline, no separate on-call surface. An MCP layer with its own uptime tier makes your data available to agents less reliably than to humans — which defeats "agent-native" before it starts.

What We'd Do Differently

  • Write the schema-drift test on day one, not after it bit us. We added it reactively; it should have been part of the initial tool scaffold.
  • Test through the protocol earlier, not just the handler. Unit-testing the Ruby method is necessary but insufficient — several real bugs only showed up once a real MCP client was in the loop.
  • Keep the transport/business-logic separation explicit from the start. Early on we let a little tenant-resolution logic leak into the transport layer for convenience. Pulling it back into the tool handlers, where it belongs, was avoidable rework.

References

Current as of July 2026. MCP tooling and client behavior are evolving quickly — details here reflect our production setup at time of writing, not a permanent spec.

Want to see the MCP server in action?

TailyX AI qualifies inbound leads for both human visitors and AI agents, from the same deterministic engine.

Request Access →
MT
Michael Thomas
Co-founder & CEO, TailyX AI