Building Uscreen’s remote, multi-tenant MCP server inside a Rails app
Uscreen, the leading all-in-one membership platform for video creators and entrepreneurs, came to Evil Martians with a working Model Context Protocol (MCP) server already tested by early adopters. To use it, a creator installed an NPM package that ran the server on their computer, entered an API key, and plugged it into the AI client of their choice. It worked, but it took a lot of setup. So, to make creators’ lives easier, Evil Martians moved the server into Uscreen’s Rails application. Now, all a creator needs to do is add the Uscreen MCP URL to their AI client, sign in, and approve access. This post covers how that implementation works: authenticating a creator, keeping requests inside the right account, defining tools around existing Rails models, and previewing destructive changes.
Let’s take Fit with Coco as an example; it’s a fitness membership with hundreds of Pilates, strength, and cardio workouts, multi-week challenges, and a seven-day free trial.
Now, imagine running all of that through an admin panel. The users with programs like this can be expected to know the perfect plank, but not necessarily their way around Vim. The panel handles individual edits and some bulk actions, but plenty of tasks span many records and can turn into an evening of scrolling, filtering, and ticking checkboxes. For example:
- Updating descriptions and tags for every workout in a six-week challenge.
- Finding workouts with the most watch time in a week and comparing monthly recurring revenue with last month.
- Listing subscriptions scheduled to cancel at the end of the billing period and free trials ending this week, grouped by plan.
These were the kinds of tasks we wanted creators to describe once rather than complete item by item. And thankfully, MCP gives an AI client a way to request the necessary application operations.
TL;DR
- Uscreen creators can connect an AI client to their account by pasting a URL, signing in, and approving access. They don’t install anything or manage API keys.
- We built the MCP server into Uscreen’s existing Rails application. Its 57 tools use the same models and business logic as the product, and new tools ship with regular deploys.
- Every request stays inside the creator’s own account and the permissions they approved.
- Destructive tools preview their effect by default, such as how many subscribers a deleted tag would affect.
- This same approach works for any Rails product that wants to give its users AI access to their accounts.
Table of contents:
- Why move the MCP server into Rails?
- Building tools on a shared base class
- Describing tools to the model
- Previewing destructive changes
- Going beyond the REST API
- Authorization in MCP 101
- What changed for Uscreen?
- Should you add an MCP server to your Rails app?
Why move the MCP server into Rails?
The original server ran locally on each creator’s computer. Creators had to install and configure it, and every new tool came with a package update. Hosting the server removed that work on its own.
Where to host was a separate decision. The local server talked to Uscreen through its public API, so it could only do what the API already offered. A new tool often meant adding a public endpoint first, and every public endpoint is a contract Uscreen has to keep stable for all the integrations built on it. Putting the server inside the Rails application removed that step:
Inside Rails, a tool is one more entry point to the application, like a controller. It calls the same models and services as the API and the admin panel, and it ships with the application instead of waiting for a new endpoint and a package release.
A remote server also takes on new responsibilities. It has to know who is behind each request (the creator or a team member with admin access), which Account they’re acting for, and what they’re allowed to do. Uscreen is multi-tenant: each creator works in a space of their own, an Account, that holds their videos, subscribers, and everything else they’ve built. Every request had to stay inside the right one.
That meant two pieces of infrastructure: an OAuth server to sign people in and a library to handle MCP requests.
For the MCP library, we had two options at the time: the official mcp gem and fast-mcp. The official gem won on three counts:
- Transport. It supported Streamable HTTP, the standard transport for remote MCP servers, with a stateless mode, so any application instance can handle any request. fast-mcp only offered the older HTTP+SSE transport, which keeps each client’s stream in one process’s memory.
- Per-request context. The official gem passes a
server_contextwith the Account, user, and token into every tool call. fast-mcp tools only receive the request headers, so each tool would have to look up the token again. - Maintenance. The official gem is maintained by the MCP project alongside the specification and was updated often. fast-mcp hadn’t had a release since September 2025.
For authentication, the MCP specification builds on OAuth 2.1, and the Rails ecosystem has a battle-tested component for that: Doorkeeper. Uscreen had even used it before, for an older version of its API, so bringing it back meant reusing the OAuth tables already in the schema rather than starting from scratch:
# ~/Gemfile
gem "doorkeeper"
gem "mcp", "~> 1.1.0"
Building tools on a shared base class
The official gem gives each tool a MCP::Tool class with a name, a description, an input schema, and a call method. Every Uscreen tool inherits from one more layer, Mcp::ApplicationTool, the same way Rails models inherit from ApplicationRecord:
# ~/app/lib/mcp/application_tool.rb
module Mcp
class ApplicationTool < MCP::Tool
include ActiveSupport::Rescuable
rescue_from ActiveRecord::RecordNotFound do |error|
failure_response("#{error.model.underscore}_not_found")
end
class << self
def description_from_template(**locals)
namespace = module_parent.name.delete_prefix("Mcp::Tools").delete_prefix("::").underscore
template = Rails.root.join("app/lib/mcp/descriptions", namespace, "#{tool_name}.txt.erb").read
description ERB.new(template, trim_mode: "-").result_with_hash(locals)
end
def call(server_context:, **args)
verify_scope!(server_context)
perform(server_context: server_context, **args)
rescue => error
rescue_with_handler(error) || raise
end
def respond_with(resource, **options)
body = ActiveModelSerializers::SerializableResource.new(resource, **options).as_json
MCP::Tool::Response.new([{ type: "text", text: body.to_json }])
end
def failure_response(error_code)
body = { error: error_code, message: I18n.t("mcp.errors.#{error_code}") }
MCP::Tool::Response.new([{ type: "text", text: body.to_json }], error: true)
end
# Scope checks: requires_scope, verify_scope!, ...
end
end
end
With that in place, a tool’s perform only contains its own logic:
# ~/app/lib/mcp/tools/cms/videos/get_tool.rb
def perform(server_context:, video_id:)
video = server_context[:account].videos.not_deleted.find(video_id)
respond_with video
end
respond_with finds the model’s serializer and wraps the result in an MCP response. A missing ID raises ActiveRecord::RecordNotFound, and the base class turns it into a video_not_found error. The same happens when the ID belongs to another Account, because the lookup starts from the Account in server_context.
server_context carries the Account, user, and scopes from the access token; we’ll show how it gets there in Authorization in MCP 101.
Describing tools to the model
Each tool tells the model what arguments it takes, when to use it, and how it behaves. Here’s the rest of videos_get, whose perform we saw above:
# ~/app/lib/mcp/tools/cms/videos/get_tool.rb
class GetTool < Mcp::ApplicationTool
tool_name "videos_get"
description_from_template
annotations(
title: "Get video",
read_only_hint: true,
destructive_hint: false,
idempotent_hint: false,
open_world_hint: false
)
input_schema(
properties: {
video_id: {
type: "integer",
minimum: 1,
description: "Internal video id. Get it from `videos_list` response `data[].id`."
}
},
required: ["video_id"]
)
requires_scope Mcp::Scopes::CMS_READ
# ...
end
The input_schema declares the arguments a tool accepts. Its descriptions can also tell the model where those arguments should come from: here, video_id points the caller to videos_list rather than asking it to infer an ID from a title.
The tool description answers a different question: when should this tool be used? We keep those descriptions in ERB templates, separate from the Ruby implementation, so they can distinguish related operations such as fetching one video, listing videos, or fetching a collection.
Here’s the full videos_get description:
<%# ~/app/lib/mcp/descriptions/cms/videos/videos_get.txt.erb %>
<purpose>
Fetch one standalone video by id. Returns the same field set as a `videos_list` row plus `resource_attachments` — the creator-uploaded files (PDFs, images, audio, etc.) linked to the video.
</purpose>
<returns>
A single video object: id, title, description, duration (seconds), tags, status, custom_filters, category_ids, collection_ids, published_at, thumbnail_url, resource_attachments.
`status` is one of 'published' | 'scheduled' | 'unpublished'.
`custom_filters` is a list of `{filter_id, filter_title, option_id, option_title}` entries — empty `[]` when the video has no filters set.
`resource_attachments` is a list of `{id, title, filename, content_type, download_url}` entries — empty `[]` when no files are attached. `download_url` is a presigned S3 URL with a 6-hour TTL.
</returns>
<use_when>
- Drill-down after `videos_list`: "show me everything about video_id=42"
- "What files are attached to this video?" (resource_attachments)
- Auditing a specific video's metadata, tags, custom_filters, or category/collection membership
</use_when>
<do_not_use_when>
- For LISTING multiple videos — call `videos_list` instead
- For name/slug lookup — `videos_get` takes the integer id only; resolve name → id via `videos_list` first
- For collections / courses / series — use `collections_get`
- For categories — use `categories_get`
</do_not_use_when>
Why so much text? The beta version of the server had no tool descriptions at all. When we moved it into Rails, we wrote one for every tool and gave them all the same structure: <purpose>, <returns>, <use_when>, and <do_not_use_when>. AI agents understand a request and act on it more reliably when it’s structured up front, so each block is wrapped in pseudo-XML tags. The <do_not_use_when> block, in particular, keeps the model from reaching for a neighboring tool.
Most of the remaining text answers a specific mistake a model would make. On Uscreen, courses and series are collections, not videos, so a model asked about “the 30-day challenge” might reach for videos_get. The <do_not_use_when> block instead sends it to collections_get.
Previewing destructive changes
Annotations such as read_only_hint and destructive_hint, like the ones on videos_get above, describe a tool’s behavior to the client; they don’t enforce permissions or require human approval.
For destructive operations, we make the default behavior cautious as well. Deleting a tag attached to three subscribers is different from deleting one attached to three thousand, so our delete_member_tag tool previews its effect when dry_run is omitted:
# ~/app/lib/mcp/tools/crm/tags/delete_tool.rb
def perform(server_context:, tag_id:, dry_run: true)
tag = server_context[:account].custom_user_tags.find_by(id: tag_id)
return failure_response("member_tag_not_found") if tag.nil?
name = tag.name
members_untagged = tag.users_count
referenced_by_automations = tag.referenced_by_automations?
return dry_run_response(tag_id, name, members_untagged, referenced_by_automations) if dry_run
tag.destroy!
success_response(tag_id, name, members_untagged, referenced_by_automations)
end
# ...
The preview returns the tag’s name, subscriber count, and whether automations reference it.
The tool’s description explains why that matters: deleting a tag strips it from every member who has it, and each removal can enroll that member in a published tag_removed automation, one run per member. So, the description tells the model to check members_untagged in the preview before passing dry_run: false.
Passing dry_run: false proceeds to deletion. This is a safer default; it is not an approval mechanism. A caller with the required permission can send dry_run: false on its first call. A preview can also become stale if the underlying data changes before a later deletion.
Going beyond the REST API
Many tools stay close to what Uscreen’s REST API already offers, but inviting customers shows where MCP goes further. Say the coach behind Fit with Coco wants to invite a dozen people from a weekend studio class and jotted down a note about each: “recovering from a knee injury,” “training for a first half marathon,” “only has 20 minutes in the morning.” The AI client fetches the store’s invite template with get_invite_email_template and writes each invitation from that person’s note, so one points to low-impact Pilates and another to strength workouts for runners. The template’s placeholders stay in place, and the coach sees who will get an email before invite_customer sends anything. The same call can grant a subscription plan, bundles, and tags.
So, the personalization is not an arbitrary thing: the creator notes determine what each email says, the template decides its shape, and nothing is sent out without the creator’s confirmation.
The REST API can create a customer, but writing the email is left to whoever calls it. Inside Rails, the tool reuses the same invitation model as the admin panel:
# ~/app/lib/mcp/tools/crm/users/invite_customer_tool.rb
def perform(server_context:, email:, subject: nil, body: nil, **grants)
# ...
Users::Invite.call(
email: email,
subject: subject,
body: body,
user: server_context[:user],
**grants
)
# ...
end
Users::Invite is the same code the admin panel uses to invite people, so an agent’s invitations count against the same quota as a creator’s.
An agent that can email customers and delete tags on a creator’s behalf needs firm limits. Let’s take a deeper look at the backend implementation, especially the parts that keep it secure.
Authorization in MCP 101
A remote server has to answer two questions on every request: who is calling, and which Account are they acting for? Uscreen answers both with OAuth, so a creator never has to copy an API key.
Connecting a client
When a creator adds Uscreen’s MCP server URL to an AI client, the client finds everything else on its own. Protected resource metadata points it to the authorization server, authorization server metadata lists the registration, authorization, and token endpoints, and dynamic client registration gives it a client_id without the creator registering an application by hand. Uscreen’s storefront and admin hosts are separate, so the metadata sends the client to the admin host, where login and consent happen.
Registration identifies the client, but it doesn’t grant access to an Account. That happens when the creator signs in and approves the client.
Doorkeeper connects that step to Uscreen’s existing login. We enable authorization-code and refresh-token grants, require PKCE, and define the permissions a client can request:
# ~/config/initializers/doorkeeper.rb
Doorkeeper.configure do
orm :active_record
resource_owner_authenticator do
current_user || redirect_to(creator_sign_in_path(redirect_url: request.fullpath))
end
grant_flows %w[authorization_code refresh_token]
force_pkce
use_refresh_token
# No default_scopes: the client has to ask for rights out loud.
optional_scopes "content_analytics.read", "finance_analytics.read", "crm.read", "cms.read",
"products.read", "cms.write", "crm.write"
custom_access_token_attributes %i[account_id audience]
force_ssl_in_redirect_uri do |uri|
uri.scheme == "http" && !%w[localhost 127.0.0.1 ::1].include?(uri.host)
end
end
The custom_access_token_attributes line adds account_id and audience to the grant and access-token records. The Account identifies the tenant; the audience identifies the MCP endpoint the token is intended for. We derive the Account from the authenticated user:
# ~/app/controllers/oauth/authorizations_controller.rb
module OAuth
class AuthorizationsController < Doorkeeper::AuthorizationsController
private
def pre_auth_params
super.merge(
account_id: current_resource_owner.account.id,
audience: params[:resource].presence || "#{request.base_url}/mcp"
)
end
end
end
The order of the merge matters. account_id follows super, so it overwrites any client-supplied value. A caller cannot select another Account by adding an identifier to the authorization request. The account comes from the user who signed in and approved access.
The audience ties the token to an MCP endpoint. The request handler checks it again when the token is used.
Checking every request
Once connected, the client sends its access token in the Authorization header. Before invoking the MCP SDK, we check the token, its audience, the Account, and the user’s membership:
# ~/app/controllers/mcp/streamable_controller.rb
def authenticate_mcp_request!
token_str = request.authorization.to_s[/\ABearer\s+(.+)\z/i, 1]
return unauthorized("invalid_request", "Missing access token") unless token_str
token = Doorkeeper::AccessToken.by_token(token_str)
return unauthorized("invalid_token", "Invalid or expired token") unless token&.accessible?
return unauthorized("invalid_token", "Wrong audience") unless token.audience == expected_audience
account = Account.active.find_by(id: token.account_id)
return unauthorized("invalid_token", "Account unavailable") unless account
user = account.admins.not_deleted.find_by(id: token.resource_owner_id)
return unauthorized("invalid_token", "User unavailable") unless user
@mcp_token = token
@mcp_account = account
@mcp_user = user
end
The Account must still be active, and the user must still be one of its administrators. A valid token alone is not enough to retain access after that membership has been removed.
After authentication, the controller builds a new MCP server for the request and passes it the Account, user, token, and scopes:
# ~/app/controllers/mcp/streamable_controller.rb
def handle
server = MCP::Server.new(
name: NAME,
version: VERSION,
tools: available_tools,
server_context: {
token: @mcp_token,
account: @mcp_account,
user: @mcp_user,
scopes: @mcp_token.scopes.to_a
}
)
transport = MCP::Server::Transports::StreamableHTTPTransport.new(
server,
stateless: true,
enable_json_response: true,
dns_rebinding_protection: false
)
# ...
end
The SDK handles the MCP messages. Tool execution receives the authenticated context through server_context.
We use the transport in stateless mode so a request does not depend on an earlier MCP request reaching the same application instance. Uscreen still stores tokens and business data; “stateless” here refers to MCP session state.
Checking scopes in every tool
Because each request gets its own server, we can only advertise the tools the token’s scopes allow:
# ~/app/controllers/mcp/streamable_controller.rb
def available_tools
scopes = @mcp_token.scopes.to_a
Mcp::ToolRegistry.tools.select { |tool| tool.available_to?(scopes) }
end
Therefore, a token without cms.write does not see write tools in tools/list. But hiding a tool is not the security boundary: permission is checked again when the tool is called. Both checks live in ApplicationTool:
# ~/app/lib/mcp/application_tool.rb
module Mcp
class ApplicationTool < MCP::Tool
class InsufficientScopeError < StandardError; end
class_attribute :required_scope, instance_accessor: false
class_attribute :scope_check_skipped, instance_accessor: false, default: false
class << self
def requires_scope(scope)
self.required_scope = scope.to_s
end
def skip_scope_check
self.scope_check_skipped = true
end
def available_to?(scopes)
return true if scope_check_skipped
required_scope.present? && Array(scopes).include?(required_scope)
end
# ...
private
def verify_scope!(server_context)
return if scope_check_skipped
raise "#{name} did not declare requires_scope" if required_scope.nil?
scopes = Array(server_context[:scopes])
return if scopes.include?(required_scope)
raise InsufficientScopeError, "Tool requires scope: #{required_scope}"
end
end
end
end
Each tool declares its scope with requires_scope and implements perform. The base class’s call, shown earlier, runs verify_scope! before perform. A tool that needs no scope, like whoami, opts out explicitly with skip_scope_check. A tool that does neither is hidden from the list and raises an error if called, so a forgotten declaration fails closed.
What changed for Uscreen?
Creators no longer need to install and maintain a local server. They connect through a URL, sign in, and approve access. New tools ship with regular Rails deploys instead of a package update, and they work directly with Uscreen’s models while each request stays tied to the authenticated Account and its permissions.
Should you add an MCP server to your Rails app?
If your users already manage their data in a Rails app, most of the work is already done. Here’s what we took away from the Uscreen project:
- Adding MCP isn’t the hard part. The official
mcpgem handles the protocol, and the first read-only version of Uscreen’s server shipped about two weeks after Doorkeeper came back into the app. The time goes into details: which Account a token belongs to, what a tool returns for a missing ID, how a destructive call previews its effect. - Authorization is the core of the work. A token has to stay bound to one Account and one endpoint, and access has to end as soon as someone loses their admin role. Cover every failure path with tests: Uscreen’s suite has a request spec for each way a token can be rejected, including a wrong audience and a demoted admin.
- Tool descriptions are written for the model. The model picks tools by their descriptions, so spell out when not to use a tool and which arguments can wipe data.
- As usual, Rails already had most of what we needed. The MCP endpoint is an ordinary controller with a
before_action. Doorkeeper handles OAuth, Rack::Attack throttles both/mcpand the open client registration endpoint, and a parameter filter keeps tool arguments with personal data out of the logs. Inside the tools,rescue_from, serializers, and I18n keepperformshort, and inviting a customer is a call to the code the admin panel already uses.
Building inside Rails means the MCP server inherits the rules the product already enforces instead of reimplementing them behind an API.