One Rails app, two dynos — running a public MCP server alongside the main SaaS

Engineering-honest writeup of how SprintFlint runs both the user-facing web app and the public MCP server (mcp.sprintflint.com) from a single Rails 8 codebase, deployed twice on Heroku via the multi-procfile buildpack. Why two dynos beat one (blast-radius isolation), why one repo beats two (no schema drift, shared auth, coordinated deploys), three things we got wrong on the first attempt (release running twice, log levels, MCP routes in main routes.rb), and when this shape starts to creak (different scaling profiles, different security posture, different language fits).

May 5, 2026  ·  9 min read  ·  SprintFlint Team

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:

  1. 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.
  2. 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.
  3. 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:migrate
    
  • Procfile.mcp (used by sprintflint-mcp via PROCFILE=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-issues polled 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 Procfile or Procfile.mcp based on PROCFILE env var
  • mcp.ru mounts 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.

Stop estimating in hours.

SprintFlint runs your sprints with story points, velocity, capacity, and retros built in. First 300 tickets free, no credit card.