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
StreamableHTTPTransportinstance per token, swapping the innerMCP::Serverper 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::UserContextfrom the token's user - sets
Current.userbefore dispatch so any downstream Rails code can see the acting user - clears
Mcp::BaseTool.user_contextandCurrent.userinafter_dispatch
Token model
Mcp::Token is the only access primitive.
It stores:
user_id(required)nametoken_digest(SHA256 of the raw token)token_prefix(for UI display only)permissions(JSON, map ofdomain => boolean)scope(always"user"in the generic package)last_used_atrevoked_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_iduser_id(denormalized from the token for fast per-user listings)tool_namedomainaction_typearguments(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
- Client sends
POST /mcp/:token Mcp::RackAppextracts the raw token, looks upMcp::Token.find_by_raw_token(raw)(digest lookup, active only)authorized?rejects any token without an associated user- Rate limit is enforced per token id
Mcp::UserContext.new(token.user, token:)is createdMcp::ServerBuilder.build(context)sets the context onMcp::BaseTooland filters tool classes bytoken.permits?(tool.mcp_domain)- The cached
StreamableHTTPTransportdispatchestools/listortools/call - The tool reads
userviaMcp::BaseTool.userand runs againstuser.items(or whatever the scoped association is) CallLogging(prepended module) logs the call toMcp::ActivityLogensurepath clearsMcp::BaseTool.user_contextandCurrent.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/listlets the client discover the permitted tool catalogtools/callinvokes a tool by name with a JSON argument objecttool_name,description, andinput_schemaare 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, notdo_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_featureinstead โ adds org + admin) - WebSocket-first transport
- externalized token auth at an API gateway
- a non-Rails stack