How to Implement Model Context Protocol (MCP) in Rails: 3 Approaches
When I started building Agentify, I took the straightforward path: hardcode the tool integrations directly into my Rails application. Need weather data? Write a method that calls the weather API. Need to search knowledge bases? Build it into the agent logic. It worked, but it wasn’t sustainable.
Then the Model Context Protocol (MCP) standard emerged, promising a way to standardize tool interactions between AI agents and external services. Instead of building every integration myself, I could tap into a growing ecosystem of MCP servers. The question was: how do you actually implement this in a real Rails application?
What is Model Context Protocol (MCP)?
If you’re new to MCP, think of it as a universal adapter for AI agents. Instead of building custom integrations for every external service — weather APIs, databases, GitHub, Slack — you implement MCP once and connect to a growing ecosystem of compatible servers.
MCP standardizes how agents discover available tools, call them with parameters, and handle responses. It’s like having a common interface whether you’re calling a weather API or querying a database. The agent doesn’t need to know the specifics — it just needs to speak MCP.
Agentify, my Rails application for building custom AI agents, was the perfect testing ground. Users create agents with different tool combinations, and I needed a scalable way to support an ever-growing set of integrations.
After weeks of experimentation, I ended up with three different approaches running side by side. Each has its place, and each taught me something different about the trade-offs between performance, flexibility, and complexity.
The Remote Approach: HTTP All the Way
The first implementation felt natural coming from a web development background. My agents make HTTP requests to remote MCP servers using JSON-RPC over HTTP.
|
|
This approach works exactly like you’d expect if you’re used to REST APIs. The agent sends a JSON-RPC request, gets a JSON response back, and passes the result to the LLM. Clean, simple, and familiar.
The beauty is in its simplicity — no process management, no local dependencies, no Docker containers to monitor. If the remote server goes down, you get a clear HTTP error. If it’s slow, you can set timeouts. If you need authentication, you handle it in the HTTP headers.
But it also highlighted the first limitation: I was dependent on external services being available and maintained. And for tools that needed to access my application’s data (like my knowledge base), going through HTTP felt like unnecessary overhead.
The Native Approach: Ruby All the Way Down
For tools that were tightly coupled to my application, I wanted something faster. Why make an HTTP request to call Ruby code when I could just… call the Ruby code directly?
|
|
This native approach gave me the best of both worlds: I could maintain the MCP interface for consistency, but skip the network overhead for internal tools. My knowledge base searches, user authentication checks, and database queries could all stay in-process while still following the MCP standard.
The performance improvement was noticeable, especially for tools that needed to make multiple database queries or access Rails models directly. No serialization overhead, no network latency, just direct method calls wrapped in the MCP protocol.
Building this taught me more about process management than I ever expected to learn. I had to handle:
- Process supervision: What happens when the Docker container crashes?
- Resource pooling: Should I spawn one process per user? Per agent? Per request?
- Stream management: How do you read complete JSON objects from STDOUT when they might span multiple lines?
- Graceful shutdown: How do you cleanly terminate Docker containers when Rails shuts down?
I ended up building what’s essentially a custom process supervisor in Ruby, complete with exponential backoff restarts, mutex-protected stream access, and proper signal handling.
The trickiest part was managing the JSON-RPC conversation over STDIO. Unlike HTTP where each request gets a clean response, you’re reading a continuous stream where messages might be split across multiple reads. I had to buffer partial JSON and parse complete messages as they arrived.
The Subprocess Approach: Docker for Everything Else
The real challenge came when I wanted to use third-party MCP servers. Most of them are distributed as either npm packages or Docker containers, and many communicate over STDIO using JSON-RPC rather than HTTP.
This is where things got interesting. I needed to spawn Docker processes from within my Rails application and manage long-running JSON-RPC conversations over STDIN/STDOUT.
|
|
This required building cross-worker coordination using PostgreSQL row locks to ensure only one Rails worker manages each Docker process. It’s more complex than I initially wanted, but it scales reasonably and keeps resource usage predictable.
Security and Configuration Challenges
Running Docker containers from within a web application raises immediate security questions. I had to:
- Configure Docker socket access: Making sure Rails can spawn containers without overly permissive permissions
- Encrypt environment variables: Tool configurations often include API keys and secrets
- Validate Docker arguments: Preventing arbitrary command injection through tool configurations
The encryption piece was particularly important. Agent configurations include sensitive data like API tokens that need to be stored securely:
|
|
Which Approach Should You Choose?
After implementing all three approaches, the decision framework became clear:
Scenario | Recommended Approach | Why |
---|---|---|
Application data access (DB, models, auth) | Native Ruby | Best performance, no network overhead |
Third-party APIs with HTTP MCP servers | Remote HTTP | Simple, reliable, clear boundaries |
npm/Docker-distributed MCP servers | Docker subprocess | Only way to access these tools |
Getting started | Remote HTTP | Easiest to understand and debug |
High-traffic production | Start with Native + Remote | Add Docker only when necessary |
Native Ruby works best for tools that need deep integration with your application data. The performance is excellent, and you maintain full control over the implementation.
Remote HTTP is perfect for external services and third-party integrations where you want clear boundaries and don’t need to manage the server infrastructure.
Docker subprocess gives you access to the broader MCP ecosystem, but comes with significant operational complexity. It’s powerful, but use it judiciously.
The biggest surprise was how much process management matters. I initially thought the Docker approach would be the simplest — just run the container and pipe JSON back and forth. In reality, it required the most infrastructure code to handle gracefully.
Production Lessons Learned
Error Handling: Each approach needs different retry strategies. HTTP calls can use standard timeout/retry patterns, but Docker processes need health checks and restart policies. I learned to implement exponential backoff for process restarts and circuit breakers for repeatedly failing tools.
Resource Management: The Docker approach can consume significant memory if you’re not careful. Monitor container resource usage and implement proper cleanup. I set memory limits on containers and kill long-running processes that exceed thresholds.
Debugging: Add structured logging for all MCP interactions. When a tool chain involves multiple calls across different approaches, request tracing becomes essential. I log every MCP request/response with correlation IDs.
Getting Started
If you want to experiment with MCP integration:
Start Simple: Begin with the HTTP approach using a public MCP server or build a basic Ruby MCP server for your own data. The learning curve is gentler, and you can see results quickly.
Example servers to try:
- Weather data: Many public weather MCP servers available
- GitHub integration:
ghcr.io/github/github-mcp-server
- Database queries: Perfect for building your first Ruby MCP server
The MCP ecosystem is growing quickly. New servers appear regularly, and the tooling around process management and security continues to improve.
Implementation Note: The full MCPClient and BaseMCPServer implementations involve quite a bit of JSON-RPC handling and error management code. I’m planning a follow-up post with the complete implementation details for those interested in the nuts and bolts.
Next Steps
The current implementation works, but there are clear areas for improvement:
OAuth integration is the biggest missing piece. Many MCP servers require OAuth flows for authentication, and handling that cleanly in the context of agent configurations is an unsolved problem.
Better monitoring would help with operational confidence. Right now I can check if processes are alive, but I don’t have good visibility into performance or error patterns.
Security hardening around the Docker integration. Running containers from a web application is inherently risky, and I want to make sure I understand the full threat model.
Building with MCP has been a journey through different architectural approaches, each with its own trade-offs. The protocol itself is solid — it’s the implementation choices that make the difference between a brittle system and a robust one.
If you’re considering MCP integration, start with the remote HTTP approach for external tools and native Ruby for internal ones. Only add subprocess management if you really need access to specific external MCP servers that don’t offer HTTP interfaces.
The ecosystem is growing quickly, and I expect the tooling around process management and security to improve significantly over the next year. For now, it’s still early enough that you’ll need to build some infrastructure yourself — but the payoff in tool flexibility is worth it.
This is part of my ongoing documentation of building with AI agents. If you’re interested in more technical breakdowns and learning logs, follow along as I continue sharing what I’m building.