Porting Playbook
Goal
Recreate the generic single-tenant, user-owned MCP feature in another Rails project without copying any domain-specific business logic.
Pre-Flight Decisions
Answer these before you port anything:
- What is the authenticated user model:
User,Account,Member, something else? (The generic package assumesUser.) - What authentication primitive is already in place: Devise, Rails 8 built-in auth, custom sessions? You need
current_useravailable in controllers. - What resource will you expose first through the CRUD tools? (Rename
Itemto your real resource before you start.) - Do you need token CRUD in the web UI, or is token management API-only?
- Will the target app support mutation tools on day one, or read-only at first?
- Do you need a
Currentmodel for audit tagging (Current.user), or will the tool logs be enough?
Phase 1: Bring Over The Shared Runtime
Add the mcp gem to the Gemfile and run bundle.
gem "mcp", "~> 0.10.0"
Create the Mcp namespace and copy these files first:
app/middleware/mcp/base_rack_app.rbapp/models/mcp/token.rb+ migrationapp/models/mcp/activity_log.rb+ migrationapp/models/mcp/tools/concerns/formattable.rbapp/models/mcp/tools/concerns/listable.rbapp/models/mcp/tools/concerns/tool_metadata.rb
At the end of this phase, the target app has the primitives needed to authenticate tokens and log tool calls, but the endpoint is not mounted yet.
Run the migrations:
bin/rails db:migrate
Phase 2: Define The User Context
Create app/models/mcp/user_context.rb. Keep it small — it exists only to bundle the acting user and token for thread-local access during a single request.
class Mcp::UserContext
attr_reader :user, :token
def initialize(user, token:)
@user = user
@token = token
raise ArgumentError, "user is required" if @user.nil?
end
end
If your target app uses a different name for the actor (Member, Account), rename the attribute but keep the object minimal.
Phase 3: Wire The Rack Endpoint
Create app/middleware/mcp/rack_app.rb. It should answer three questions:
- Is the token authorized for this endpoint? (has an associated user, scope
"user", not revoked) - How do I build the server for this token? (via
Mcp::ServerBuilder.build(context)) - What request-local flags do I need before and after dispatch? (set and clear
Current.userandMcp::BaseTool.user_context)
Mount it in config/routes.rb above authenticated blocks:
mount Mcp::RackApp.new, at: "/mcp"
After this phase, a POST /mcp/some-token request returns 401 or hits the builder.
Phase 4: Port The Base Tool And Server Builder
Bring over:
app/models/mcp/base_tool.rbapp/models/mcp/server_builder.rb
Adapt these pieces carefully:
- the list of tool namespaces in
TOOL_NAMESPACES - the context accessor names on
Mcp::BaseToolif you changed them (user,token) - the permission predicate used by the builder (
token.permits?(domain)) - the
CallLoggingmodule — keep it exactly as-is unless you have a reason to change the logging contract
Once this is done, the target project serves tools/list (empty) even before any tool classes exist.
Phase 5: Build The First CRUD Domain
Do not port multiple domains at once. Rename Item to your real first resource and port these five tools in order:
list_<resource>get_<resource>create_<resource>update_<resource>delete_<resource>
Use the patterns from 04_tool_patterns.md:
- each tool class handles one action
- each tool has a precise
tool_name - each tool declares
mcp_domainandmcp_action - every query starts from
user.<association>, never from the unscoped model class - list tools include
ID:in every row so the AI can feed ids into follow-up tools
Register the namespace in Mcp::ServerBuilder::TOOL_NAMESPACES:
TOOL_NAMESPACES = %w[
Mcp::Tools::Items
].freeze
Phase 6: Add Token Management
If the target app needs human-operated token lifecycle management, add:
Mcp::BaseController(authenticates the user, applies the app layout)Mcp::TokensController(index / new / create / edit / update / destroy / regenerate)Mcp::TokenPolicy(Pundit policy so users only see their own tokens)- Views for the token list, new token form, and permission toggles
- Nested routes under
namespace :mcp, path: "settings/mcp"
The runtime works without the UI. Production teams usually need a safe place to issue and rotate tokens, so this is strongly recommended.
Phase 7: Test Before Adding More Tool Surface
Before the tool catalog grows, validate these scenarios:
- Valid token can call
tools/listand sees exactly the permitted tools - Revoked token returns 401
- Missing token returns 401
- Rate limit returns 429 after the configured threshold (60/minute/token by default)
tools/callon a permitted tool creates anMcp::ActivityLogrow- Tool cannot access another user's records (scope leak test)
See 05_security_ops_and_testing.md for the full test matrix.
Phase 8: Expand The Tool Catalog
Once the Item domain is stable, add more domains one at a time. For each:
- create
app/models/mcp/tools/<domain>/with up to five CRUD tool classes - add the namespace string to
Mcp::ServerBuilder::TOOL_NAMESPACES - add the permission key to
Mcp::Token::PERMISSION_KEYS - update the token form checkboxes so users can opt in
Copy Matrix
Copy almost directly
Mcp::BaseRackAppMcp::BaseToolMcp::UserContextMcp::Tools::Concerns::FormattableMcp::Tools::Concerns::ListableMcp::Tools::Concerns::ToolMetadata
Copy, then adapt to the target domain
Mcp::Token(rename permission keys to match your domains)Mcp::ActivityLog(adjust sanitization patterns if needed)Mcp::RackApp(renameCurrent.userif your app uses something else)Mcp::ServerBuilder(fill inTOOL_NAMESPACES)- routes and controllers
Rebuild from scratch
- tool classes for each domain you expose
- policies tied to your own authorization model
- token UI styled to your own design system
Suggested Rollout In Another Project
Smallest viable export
- one MCP endpoint
- token model + activity log
- one domain (your first real resource) with
listandget
Parity export
- full CRUD for one resource (list, get, create, update, delete)
- token management UI
- activity viewer
- per-domain permission toggles
- rate limiting validated under production-like load
Common Adaptation Points
Different actor model
If the target app authenticates something other than User (for example Member, Account), rename the attribute on Mcp::UserContext and the accessor on Mcp::BaseTool. Keep the pattern identical.
No Current model
You can omit Current.user at first. The MCP runtime does not require it. It is only used so downstream write activity can be tagged as MCP-originated.
Different auth stack
The MCP runtime does not care how tokens are created — only that they exist and have a user_id. If your target app uses Rails 8 built-in auth, Devise, or a custom session layer, the only thing that changes is the before_action :authenticate_user! in Mcp::BaseController.
Different authorization stack
If you do not use Pundit, replace Mcp::TokenPolicy with inline scoping in the controller: current_user.mcp_tokens.find(params[:id]).