When we relaunched the public MCP server at mcp.sprintflint.com, the natural question from the engineering side of our audience was: “is that a separate codebase?” It isn’t. SprintFlint is one Rails 8 app, deployed twice on Heroku — once as the user-facing web app, once as the MCP server — with the same code in both. This post is the engineering-honest version of why we picked that shape, what the multi-procfile buildpack actually does for you, and the failure modes we’ve watched for in the first two weeks of running it in production.
The decision tree
Three plausible shapes when you want to ship an MCP server next to a Rails SaaS:
- One Rails app, one dyno. Mount the MCP server inside the main app’s routes. Smallest blast-radius from a code-organisation perspective, but every memory leak in MCP code crashes the user-facing app and every spike in MCP traffic eats web request capacity.
- One Rails app, separate dynos via multi-procfile. Same codebase, separate processes. MCP traffic and web traffic land on different dynos behind different domains, but they share models, auth, migrations, and deploys.
- Separate Rails app, separate repo. MCP server is its own service. Maximum isolation. Maximum maintenance overhead — you’ve now got duplicated auth code, models, and migration coordination problems.
Most SaaS startups should pick (2). We did. Here’s why.
Why “one repo, two dynos” is the right default
Three properties matter when an MCP server sits beside a SaaS:
Schema is shared. The MCP tools list issues, projects, sprints. Those are the same models the web app uses. Duplicating them across two repos is the worst of both worlds — schema drift you don’t notice until a tool returns wrong data.
Auth is shared. The same bearer token works in REST and MCP. Implementing token verification twice means two places to fix when you tighten security. Also: account scoping, organisation membership, rate-limit ledgers — all single-source.
Deploy is coordinated. When you ship a model change, both the web app and the MCP server need it within the same deploy window. A split-repo design forces you to think about migration ordering across two deploys; a multi-procfile design just runs the migration once and both dynos pick it up on next boot.
The blast-radius argument cuts the other way: if you serve MCP from the same dyno as web, a bug in MCP-specific code can take down the SaaS. Two-dyno-from-one-codebase is the compromise that gives you isolation at runtime without the duplication of two repos.
How the multi-procfile buildpack actually works
Heroku’s multi-procfile buildpack is twelve lines of bash. It reads an environment variable (PROCFILE) on slug compile, copies the file you point it at to Procfile, and lets the standard Ruby buildpack take over.
For our setup that means:
Procfile(default — used by sprintflint-production):web: bundle exec puma -C config/puma.rb worker: bundle exec sidekiq release: bundle exec rails db:migrateProcfile.mcp(used by sprintflint-mcp viaPROCFILE=Procfile.mcp):web: bundle exec puma -C config/puma.mcp.rb mcp.ru release: bundle exec rails db:migrate
Both apps are built from the same git push. The difference is which Procfile each one sees. mcp.ru mounts only the ActionMCP engine (no devise, no autoplay, no admin), so we get a smaller boot footprint and a different routing surface without any code duplication.
What we got wrong on the first attempt
Three things, in order of how long they cost us:
1. Putting MCP routes in the main routes.rb. We did this for the first deploy because it was the path of least resistance. Then realised the MCP server boots into a config that includes Sidekiq web, devise, and the entire admin UI, and that the routing surface for a public MCP server should be exactly one mount point. Moved to a separate mcp.ru rackup file that mounts only the ActionMCP engine.
2. Forgetting that release: runs twice. Both dynos compile the same slug, so the release phrase ran twice on every deploy. For Rails this is mostly fine — db:migrate is idempotent — but it doubles deploy time. We could have removed the release phase from Procfile.mcp entirely (the main app’s release covers the migration) but the symmetry helps with rollback safety: either dyno can boot independently if the other is broken.
3. Same RAILS_LOG_LEVEL for both. Initially we had info everywhere. The MCP dyno generates ~10x the request volume of the web dyno (small JSON-RPC calls, polled by always-on agents) and was burning through log retention. Switched MCP to warn for HTTP requests, kept Sidekiq logs verbose. Per-app Heroku config did this cleanly.
Auth: one token, two surfaces
The shared bearer token is a feature, not a smell. Here’s why we’re confident in that.
The token is generated in the user’s account settings, scoped to their organisation, and rate-limited at the organisation level (600 req/min). Both the REST API and the MCP server validate the same token against the same ApiToken model. There’s no concept of “MCP-only token” or “REST-only token” because the underlying capability is the same — read and write organisation data within the user’s permissions.
The arguments against a unified token surface:
- “What if one is compromised?” Both are equally compromised because they read the same model. Splitting tokens just doubles the surface area.
- “What if I want to revoke MCP access without revoking REST?” Generate two tokens. Revoke one. The token model already supports multiple per user; we surface them in the UI.
- “What about scopes?” Scopes are a real concern when the surfaces have meaningfully different permission shapes. Ours don’t. If we ship admin-only tools to MCP later, that’ll be the time to introduce scoping — not today.
Monitoring: two dashboards, one log shape
Both dynos ship to the same log drain, but with dyno=web.N and dyno=mcp_web.N prefixes that let us separate them in the queries. Three top-level dashboards:
- Latency by dyno: web p95 lives in 200-400ms, MCP p95 lives in 50-150ms. They’re different shapes — web does ERB rendering, MCP does JSON-RPC + DB query.
- Error rate by dyno: most useful in deploy windows. If we deploy a Rails change and only one dyno’s error rate spikes, the bug is in code that only one of them runs (e.g. the autoplay UI or the MCP engine).
- Token rate-limit hits by dyno: tells us whether agents are hitting the MCP server in tight loops (they sometimes do —
my-issuespolled every 30 seconds is a thing) and whether to consider per-tool quotas later.
When NOT to use this shape
A few cases where the two-dyno-one-repo design starts to creak:
- Wildly different scaling profiles. If MCP traffic grows 100x faster than web (it might — agents make many small calls per human session), eventually the deploy coordination becomes a constraint. Splitting into a separate codebase lets MCP team ship daily without regression-testing the web flow.
- Different security posture. If MCP needs PCI compliance and web doesn’t, a separate VPC / repo / audit trail starts looking attractive.
- Different language fits. Rails is fine for both today, but if MCP becomes a high-throughput streaming service, we might rewrite it in Go or Rust. That’s the day this design ends.
For now, the two-dyno-one-repo design is paying its rent.
TL;DR — the engineering-honest summary
- One Rails app, two Heroku apps, same git remote
- Multi-procfile buildpack picks
ProcfileorProcfile.mcpbased onPROCFILEenv var mcp.rumounts only the ActionMCP engine — smaller boot, narrower attack surface- Shared models, shared auth, shared deploys — different runtime, different domain
- Compromise between “one app does it all” (no isolation) and “two repos” (duplication you’ll regret)
- Will revisit when MCP scales 100x faster than web or needs different compliance posture
If you’re building a public MCP server next to a Rails SaaS, this is the shape to start with. Go from here.
Related
- Sprint state in your agent — practitioner guide
- Claude Desktop for sprint management
- /mcp landing page — full server documentation
- /tools/mcp-config-generator — paste-ready config per editor