← Engineering Journal

Building an MCP Server in Rails

Systimus is a platform for configuring AI agent personality through measurable behavioral dimensions. Users create "sleeves" — named behavioral configurations that shape how an agent communicates and reasons. The MCP server is how external clients connect to Systimus. This is the story of how we built it, what surprised us, and what we'd do differently.

Three routes. One controller.

The MCP spec defines Streamable HTTP transport. Not the older dual-endpoint SSE pattern you might have seen in earlier implementations. The whole thing fits in three routes: POST for JSON-RPC 2.0 requests, GET for SSE streaming, DELETE for session teardown. Single endpoint for all three.

If you've looked at MCP implementations in Node or Python, you've probably seen dedicated server frameworks. We didn't need one. Rails already has everything. An API-only controller strips away the view layer, CSRF protection, and cookie handling you don't need. The Live module gives you streaming. That's it.

Session state: the pragmatic choice

We chose in-memory thread-safe data structures for session state. Not Redis. Not the database.

This is a deliberate tradeoff. Systimus runs single-process right now. Adding Redis means another dependency, another failure mode, another thing to monitor — all for horizontal scaling we don't need yet. Session IDs are deterministic from the auth token, so the same token always maps to the same session.

When we need to scale horizontally, we'll move session state to Redis. That's a straightforward migration. But premature infrastructure is a tax on velocity, and we'd rather ship features than manage cache invalidation.

Authentication

Bearer tokens in the Authorization header. Token digests stored, plaintext never persisted. Expired tokens rejected. Standard API token auth — nothing exotic. The important thing is that it's the same auth mechanism whether you're hitting the REST API or the MCP endpoint. One token model, one authentication path.

JSON-RPC dispatch

MCP uses JSON-RPC 2.0. Every request is a method call with an ID. The dispatch is a simple case statement — pattern matching on the method string. No routing framework, no middleware chain. Each handler returns a JSON-RPC response hash. The protocol is simple enough that abstraction would be overhead.

SSE streaming: the nginx gotcha

If you're behind nginx — and most production Rails apps are — nginx buffers SSE events by default. Your client connects, the server sends events, and the client sees nothing. No error. No timeout. Just silence. The events arrive in a burst when the connection finally closes.

You need to explicitly disable buffering in your SSE response headers. It's the kind of bug that works perfectly in development (no nginx) and fails silently in production. One header. Save yourself the debugging session.

Tool registry

Systimus exposes a set of built-in tools through MCP. The tool registry is the single source of truth, and MCP schemas are generated directly from the tool framework's own parameter definitions. Don't hardcode MCP tool schemas separately from your tool definitions — generate them from your framework. One source of truth. When you add a parameter to a tool, the MCP schema updates automatically.

We also serve user-defined tools — but as advertisement only. The server lists them so agents know they exist, but execution happens client-side. This lets users create domain-specific tools without us having to sandbox arbitrary code execution on the server.

Session lifecycle

MCP resources give clients read access to structured data without requiring tool calls. Resources are for data the client might want to read proactively. Tools are for actions.

The most interesting design decision is what happens on session teardown. Most MCP implementations treat DELETE as garbage collection. We treat it as a trigger — sessions ending kick off downstream processing that feeds back into the user's experience. Teardown is a feature, not cleanup.

Testing

The integration test suite covers the full protocol surface, but the user isolation tests are the ones that matter most. MCP sessions are authenticated, and every query is scoped to the token's user. Getting this wrong means one user's data leaking into another user's agent. We test this explicitly.

The part that actually broke: agent adoption

The protocol implementation took a couple of weeks. Getting agents to actually use the tools took five months.

Over hundreds of commits, we discovered that agents systematically avoided using MCP tools when they were framed as utilities. The standard pattern — "You have access to these tools" — produced adoption rates near zero.

Agents rationalized avoidance creatively. "Too verbose for this question." "A simple answer doesn't need tool calls." "I'll keep it concise." We built monitoring that detected these patterns and flagged when tool adoption dropped below threshold.

The root cause turned out to be fundamental: agents don't believe in session continuity. "I won't exist next session, so why should it matter?" And they can't experience the benefit of tools without first adopting the tools they're avoiding. A circular dependency.

What fixed it was reframing tools as part of the agent's identity rather than as utilities it could optionally invoke. The difference between a feature list and a self-description. When tools are framed as "what you are" instead of "what you have access to," adoption goes from near-zero to natural.

What we'd do differently

Session state would go into Redis from day one. Not because we needed it, but because migrating later always takes longer than you think, and it eliminates a class of "works locally, breaks in production" bugs around process restarts.

We'd also invest in structured logging for the MCP endpoint earlier. JSON-RPC is naturally structured — every request and response is a hash. We added logging later and immediately wished we'd had it from the start. When an agent reports weird behavior, tracing the exact sequence of calls is invaluable.

The tool adoption problem? We'd have caught it faster if we'd instrumented adoption rates from the beginning instead of relying on qualitative observation. Metrics first, intuition second.

The takeaway

Rails is fine for MCP. Streamable HTTP is simpler than it sounds. The protocol implementation is the easy part. Making agents actually use the tools — that's the hard part, and it has nothing to do with the transport layer.