# Building Uscreen’s remote, multi-tenant MCP server inside a Rails app

> Source: <https://evilmartians.com/chronicles/building-uscreens-remote-multi-tenant-mcp-server-inside-a-rails-app>
> Published: 2026-11-11 00:00:00+00:00

# Building Uscreen’s remote, multi-tenant MCP server inside a Rails app

[Uscreen](https://www.uscreen.tv), 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](https://fitwithcoco.com/) 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.

## 

- 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**:

## 

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](https://github.com/modelcontextprotocol/ruby-sdk) and [fast-mcp](https://github.com/yjacquin/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_context` with 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](https://github.com/doorkeeper-gem/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"
```

## 

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:

``` python
# ~/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](#authorization-in-mcp-101).

## 

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`.

## 

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:

``` python
# ~/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.

## 

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:

``` python
# ~/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.

## 

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.

### 

When a creator adds Uscreen’s MCP server URL to an AI client, the client finds everything else on its own. [Protected resource metadata](https://datatracker.ietf.org/doc/html/rfc9728) points it to the authorization server, [authorization server metadata](https://datatracker.ietf.org/doc/html/rfc8414) lists the registration, authorization, and token endpoints, and [dynamic client registration](https://datatracker.ietf.org/doc/html/rfc7591) 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.

### 

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.

### 

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.

## 

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.

## 

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`mcp` gem 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](https://github.com/rack/rack-attack) throttles both`/mcp` and 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 keep`perform` short, 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.
