Architecture And Connection Flow

Core Idea

This implementation keeps MCP inside the Rails app rather than running a separate MCP service. The Rails app mounts a Rack endpoint that authenticates a token, builds an MCP::Server scoped to the token's owning user, and delegates JSON-RPC requests to the Ruby mcp gem.

There is one server:

  • User MCP at /mcp/:token

Tokens belong to a User. The endpoint resolves the user from the token, injects a UserContext into thread-local state, and only exposes tools whose mcp_domain the token is permitted to use.

Runtime Layers

AI client
  -> POST /mcp/:token
  -> Mcp::RackApp authenticates token
  -> Mcp::ServerBuilder selects permitted tool classes
  -> MCP::Server receives tools/list or tools/call
  -> tool class runs against user-scoped associations (user.items, etc.)
  -> response returns as JSON-RPC payload
  -> Mcp::ActivityLog stores the call summary

Shared Runtime Components

Rack base

Mcp::BaseRackApp owns protocol-agnostic transport concerns:

  • extract the token from the URL path
  • look up the raw token through a SHA256 digest
  • enforce a per-token in-memory rate limit
  • reuse a StreamableHTTPTransport instance per token, swapping the inner MCP::Server per request
  • normalize 401, 429, and 500 JSON-RPC error responses
  • always clear thread-local state after dispatch

Concrete Rack app

Mcp::RackApp < Mcp::BaseRackApp owns the user-scoped authorization:

  • accepts only scope: "user" tokens that have an associated user
  • builds a Mcp::UserContext from the token's user
  • sets Current.user before dispatch so any downstream Rails code can see the acting user
  • clears Mcp::BaseTool.user_context and Current.user in after_dispatch

Token model

Mcp::Token is the only access primitive.

It stores:

  • user_id (required)
  • name
  • token_digest (SHA256 of the raw token)
  • token_prefix (for UI display only)
  • permissions (JSON, map of domain => boolean)
  • scope (always "user" in the generic package)
  • last_used_at
  • revoked_at

The raw token is never stored. It is returned once at creation or regeneration and must be captured by the caller.

Activity log

Mcp::ActivityLog records every tool call:

  • mcp_token_id
  • user_id (denormalized from the token for fast per-user listings)
  • tool_name
  • domain
  • action_type
  • arguments (sanitized JSON)
  • result_summary (truncated preview)
  • status_code
  • timestamps

User context

Mcp::UserContext is a tiny value object that holds the acting user and the token for the current request. It is stored in Thread.current[:mcp_user_context] through Mcp::BaseTool.user_context= so every tool class can read user and token without receiving them as arguments.

Request Lifecycle

  1. Client sends POST /mcp/:token
  2. Mcp::RackApp extracts the raw token, looks up Mcp::Token.find_by_raw_token(raw) (digest lookup, active only)
  3. authorized? rejects any token without an associated user
  4. Rate limit is enforced per token id
  5. Mcp::UserContext.new(token.user, token:) is created
  6. Mcp::ServerBuilder.build(context) sets the context on Mcp::BaseTool and filters tool classes by token.permits?(tool.mcp_domain)
  7. The cached StreamableHTTPTransport dispatches tools/list or tools/call
  8. The tool reads user via Mcp::BaseTool.user and runs against user.items (or whatever the scoped association is)
  9. CallLogging (prepended module) logs the call to Mcp::ActivityLog
  10. ensure path clears Mcp::BaseTool.user_context and Current.user

Why The User MCP Is Safe By Structure

Tools do not receive a user id from the caller. The user is resolved before tool execution and injected into thread-local context. A tool can only access the current user's records unless the tool author explicitly bypasses the scoped association (user.items vs Item.where(...)).

Always start queries from user.<association>. Never from Item.all or Item.where(user_id: ...).

How It Connects To AI Clients

The implementation uses Streamable HTTP transport, so MCP hosts only need a URL.

Claude Desktop / Claude Code

{
  "mcpServers": {
    "my-app": {
      "url": "https://your-app.com/mcp/YOUR_TOKEN"
    }
  }
}

Protocol exchange

  • tools/list lets the client discover the permitted tool catalog
  • tools/call invokes a tool by name with a JSON argument object
  • tool_name, description, and input_schema are the only hints the model has when choosing tools

That means the AI integration quality is not a separate adapter layer. It is the combination of:

  • precise tool names (list_items, create_item, not do_item)
  • short descriptions that name the resource and the action
  • tight input schemas (required fields explicit, optional fields typed)
  • consistent response formats (JSON for structured data, text for confirmations)

Transport And Server Construction Details

Per-request server construction

The builder creates a new MCP::Server on every request. This guarantees permission filtering reflects the current token state — if the user revokes a permission mid-session, the very next request no longer exposes that tool.

Per-token transport reuse

The transport object is cached by mcp_token.id inside the Rack app. The inner @server ivar is swapped on each request. This reduces transport setup churn without leaking stale tool state.

Thread-local context

Both the user context and the Current.user accessor use Thread.current rather than ordinary class variables. This prevents request leakage under Puma or any threaded server. The after_dispatch hook clears them in an ensure block.

Decision Summary

Use this structure when the target project needs:

  • a first-class MCP endpoint inside the main Rails app
  • user-level isolation enforced before tool execution
  • multiple tokens per user with different permission sets
  • AI clients that connect by URL only

Do not use this exact structure unchanged if the target project needs:

  • multi-tenant scoping (use ../export_mcp_feature instead — adds org + admin)
  • WebSocket-first transport
  • externalized token auth at an API gateway
  • a non-Rails stack