actionmodelcontext
A Rails-flavoured MCP server layer on top of the official mcp SDK. Tools, resources and prompts are plain Ruby classes under app/mcp/, discovered by Zeitwerk, and served by a controller concern over the Streamable HTTP transport (stateless, JSON responses).
Requires Ruby 3.3+, Rails 7.2+ and mcp ~> 1.8.
Installation
bundle add actionmodelcontext
bin/rails generate action_model_context:install
The install generator creates ApplicationTool, ApplicationResource and ApplicationPrompt under app/mcp/, an McpController, and this route:
resource :mcp, controller: :mcp, only: %i[show create destroy]
Files under app/mcp/tools, app/mcp/resources and app/mcp/prompts are autoloaded with that first directory level collapsed: app/mcp/tools/billing/refund_tool.rb defines Billing::RefundTool. Other directories under app/mcp keep theirs, so the code the primitives share can live next to them: app/mcp/billing/tax_rate.rb defines Billing::TaxRate. The controller discovers primitives through ApplicationTool, ApplicationResource and ApplicationPrompt and their descendants.
Only concrete classes are exposed. A tool or prompt is concrete when it, or a parent below the gem's base class, defines call. A resource is concrete when it declares its own uri and defines read (uri is not inherited). An intermediate base such as Admin::BaseTool < ApplicationTool that only adds rescue_from handlers or arguments is therefore not listed, while its subclasses that define call are. A base that defines call itself is exposed as a tool.
ApplicationResourcecollides with Graphiti and JSONAPI::Resources, which also define a top-levelApplicationResource(inapp/resources/). The controller looks up MCP resources through that constant on every request, so when it names the other library's class, every MCP request fails with a 500,tools/listandtools/callincluded, not just resources. An app using one of these libraries has to resolve the clash before installing this gem.
Tools
class EchoTool < ApplicationTool
argument :message, :string, required: true
argument :times, :integer
def call(message:, times: 1) = * times
end
argument(name, type, required: false, null: false, **keywords) where type is one of :string, :integer, :number, :boolean, :array, :object. null: true also accepts null ("type": ["string", "null"]). The other keywords are those of JSON Schema 2020-12, the dialect MCP uses (description:, enum:, items:, minimum:…), their names camelized: argument :ids, :array, items: { type: 'integer' }, max_items: 50 emits maxItems. A hash such as items: goes through as is, so write its keys in JSON (maxLength). A keyword outside it, a typo such as max_itmes: for instance, raises ArgumentError when the class loads. The SDK validates the arguments against the schema before call, so minimum, maxItems and the like are enforced; format is not. The tool name defaults to the underscored class name without the Tool suffix (echo); override it with tool_name 'other'. title, description and annotations(**hints) are class-level macros.
arguments_from(properties, required: []) declares one argument per entry of a JSON Schema properties hash, the names listed in required being required, so one set of fields can serve a create tool and an update tool:
TASK_FIELDS = { title: { type: 'string' }, due_on: { type: %w[string null], format: 'date' } }.freeze
class CreateTaskTool < ApplicationTool
arguments_from TASK_FIELDS, required: %i[title]
end
Keys may be symbols or strings, as in a schema loaded from JSON, and type: [..., 'null'] becomes null: true. A property without a single type, or with its own required list, raises ArgumentError; a required nested in items or properties goes through as is.
call may return a String, a Hash (sent as text and as structured content), an Array, or an MCP::Tool::Response. Raise ActionModelContext::Error to return an error result to the model with your message; any other exception is reported through Rails.error (severity: :error, source: 'actionmodelcontext'), with the JSON-RPC method and the primitive's name or URI as mcp_method and mcp_name in the context, never the arguments. rescue_from works inside a primitive:
class WhoAmITool < ApplicationTool
rescue_from(KeyError) { raise ActionModelContext::Error, 'Unknown user' }
def call = { user: context.fetch(:user) }
end
A handler that returns normally has handled the exception, as in a Rails controller: its return value becomes the result, converted like the value of call (or of read in a resource). To produce an error result instead, raise ActionModelContext::Error from the handler, as above. When no handler matches the exception, its cause is tried next, as in Rails.
To wrap every call of a tool base, override the private invoke(arguments) and call super, as process_action in a controller. It receives the arguments as the client sent them, before they are sliced to the declared ones, and returns the value of call before its conversion. rescue_from covers it:
class ApplicationTool < ActionModelContext::Tool
private
def invoke(arguments)
raise ActionModelContext::Error, 'Archived records are read-only' if arguments.key?(:archived)
Current.set(user: context[:user]) { super }
end
end
Resources
class ReadmeResource < ApplicationResource
uri 'app://readme'
def read = 'Hello'
end
class UserResource < ApplicationResource
uri 'users://{id}'
def read(id:)
raise ActionModelContext::Error, 'Unknown user' if id == '0'
{ id:, viewer: context[:user] }
end
end
A URI containing {variable} is registered as a resource template, and the variables are passed to read as keyword arguments. read may return a String (text/plain), or a Hash / Array (application/json); mime_type '...' overrides this and is inherited by subclasses, unlike uri. An ActionModelContext::Error becomes an invalid_params JSON-RPC error.
Prompts
class SummarizePrompt < ApplicationPrompt
argument :topic, required: true
def call(topic:) = "Summarize #{topic} in three points"
end
argument(name, required: false, title: nil, description: nil): MCP gives a prompt argument no type, only these fields. call returns a String (one user message) or an Array of { role:, content: } hashes.
Generators
bin/rails generate action_model_context:tool Billing::Refund amount:integer:required reason
bin/rails generate action_model_context:resource User --uri "users://{id}"
bin/rails generate action_model_context:prompt Summarize topic:required
Each generator drops its own suffix (Tool, Resource, Prompt) from the name, so action_model_context:tool FindUserTool generates FindUserTool, not FindUserToolTool. Each one also generates a spec (RSpec) or test (Minitest) following your test_framework. Until you implement it, the generated call or read raises ActionModelContext::Error, so a fresh stub answers an error result (isError for a tool, a JSON-RPC -32602 error for a resource or prompt) instead of a 500.
Authentication and context
The controller is a normal Rails controller, so authenticate with a before_action, and expose what tools need through mcp_context. Whatever it returns is available as context in every tool, resource and prompt:
class McpController < ApplicationController
include ActionModelContext::Server
before_action :authenticate_user!
private
def mcp_context = { user: current_user }
end
The gem imposes no authentication unless config.action_model_context.oauth.token_verifier is set (see OAuth). Three hooks are available, all private and overridable:
| Hook | Default | Purpose |
|---|---|---|
mcp_context |
{} |
The context given to every primitive |
mcp_server_options |
{ name: <app module name, underscored> } |
Keyword arguments for MCP::Server.new (for example name:, version:, instructions:) |
mcp_transport_options |
{} |
Keyword arguments for MCP::Server::Transports::StreamableHTTPTransport.new |
stateless, enable_json_response and serve_subscriptions_listen are pinned and cannot be overridden through mcp_transport_options: the transport is built per request, so a stateful transport would leak a session reaper thread on every call.
OAuth
The MCP server is an OAuth 2.1 resource server: clients call it with bearer tokens issued by an authorization server (Doorkeeper in the same app, or an external identity provider), and it never issues them. Everything is set under config.action_model_context.oauth, in config/application.rb or per environment:
config.action_model_context.oauth.resource = 'https://example.com/mcp'
config.action_model_context.oauth. = [ 'https://auth.example.com' ]
config.action_model_context.oauth.scopes = %w[mcp:tools]
resource is the public URL of the MCP endpoint, prefix included. With it, the gem publishes the RFC 9728 Protected Resource Metadata document, through which clients discover the authorization server, using the SDK's middleware: at the RFC 9728 path when requests for it reach the app, and at /.well-known/oauth-protected-resource relative to the app, the path an app mounted under a prefix receives. Without resource, the gem publishes nothing. scopes are advertised in the document and in the challenges. The optional resource_name and resource_documentation go into the document too, and extra adds the other RFC 9728 fields (extra = { resource_policy_uri: '...' }); bearer_methods_supported stays ["header"], the only place the SDK reads a token from.
Every 401 points at that document. Its URL is resource with the path of the MCP endpoint replaced by /.well-known/oauth-protected-resource (https://example.com/backend/app/.well-known/oauth-protected-resource for https://example.com/backend/app/mcp), so it keeps a prefix that a proxy strips before the request reaches the app, without relying on forwarded headers.
Then either the gem checks the token, or the app decides who calls.
The gem checks the token
token_verifier is a lambda that receives the document and returns the SDK verifier. It runs once the app has loaded, and again after each code reload in development. The concern then checks the bearer token before the MCP actions: a missing or invalid token gets a 401, a token without all of scopes a 403 insufficient_scope, both with the SDK's challenge. mcp_access_token returns the verified MCP::Server::OAuth::AccessToken:
config.action_model_context.oauth.token_verifier = ->() {
MCP::Server::OAuth::JWTVerifier.new(resource_metadata: , jwks_uri: 'https://auth.example.com/.well-known/jwks.json')
}
class McpController < ApplicationController
include ActionModelContext::Server
private
def mcp_context = { user: User.find_by(id: mcp_access_token.subject) }
end
JWTVerifier (which needs the jwt gem) checks the signature, and the issuer and audience against the document, and caches the signing keys. IntrospectionVerifier asks the authorization server instead, and any object that responds to verify(token) and returns an MCP::Server::OAuth::AccessToken works too (see MCP::Server::OAuth::TokenVerifier).
With Doorkeeper in the same app, authorization_servers names the app itself and the verifier looks the token up in Doorkeeper's table:
config.action_model_context.oauth.token_verifier = ->() { DoorkeeperTokenVerifier.new }
# app/mcp/doorkeeper_token_verifier.rb
class DoorkeeperTokenVerifier
def verify(token)
access_token = Doorkeeper::AccessToken.by_token(token)
raise MCP::Server::OAuth::InvalidTokenError unless access_token&.accessible?
MCP::Server::OAuth::AccessToken.new(
token:, scopes: access_token.scopes.to_a, subject: access_token.resource_owner_id.to_s,
expires_at: access_token.expires_at&.to_i, client_id: access_token.application&.uid,
)
end
end
Doorkeeper serves /oauth/authorize and /oauth/token with PKCE. MCP clients need two more things that Doorkeeper 5.9 does not provide:
- the authorization server metadata (RFC 8414) at
/.well-known/oauth-authorization-server, whoseissuermust equal the URL inauthorization_serversand whosecode_challenge_methods_supportedmust includeS256; - a way to obtain a
client_id: a Doorkeeper application registered beforehand, support for Client ID Metadata Documents, or Dynamic Client Registration (RFC 7591).
Doorkeeper tokens are not bound to the MCP endpoint: any token of the app that carries the required scope is accepted.
The app decides who calls
Without token_verifier, the app authenticates the request itself, or a proxy in front of it does, as in Authentication and context. When it rejects a request, request_mcp_authentication answers the SDK's 401 challenge:
class McpController < ApplicationController
include ActionModelContext::Server
before_action { request_mcp_authentication unless user_signed_in? }
end
Host and Origin checks (DNS rebinding)
The concern builds the transport with dns_rebinding_protection: false. The transport's own check accepts only 127.0.0.1, ::1 and localhost as Host, which would reject every real deployment. Host checking is left to Rails' config.hosts (ActionDispatch::HostAuthorization).
- In development, Rails allows local hosts by default. In production,
config.hostsis empty, which means no Host check at all unless your app configures one. Setconfig.hostsin production. - The same flag also disables the transport's Origin check.
- To turn the protection back on, override the option:
def = { dns_rebinding_protection: true, allowed_hosts: %w[mcp.example.com] }
I18n
Titles, descriptions, and argument titles and descriptions are looked up per request, in the current locale:
en:
action_model_context:
tools:
billing_refund:
title: Refund
description: Refund an invoice
arguments:
amount: Amount in cents
resources:
user:
description: A user
prompts:
summarize:
description: Summarize a topic
arguments:
topic:
title: Topic
description: What to summarize
The keys are action_model_context.{tools,resources,prompts}.<name>.{title,description,arguments.<arg>}, where <name> is the primitive name (billing_refund for Billing::RefundTool). arguments.<arg> holds the argument's description, or a hash with its title and description; a tool argument reads only the description. A value declared in the class (description '...', argument ..., description: '...') wins over I18n. I18n fallbacks apply only when config.i18n.fallbacks is enabled, which the config/environments/production.rb generated by Rails does. A missing key omits the field: "Translation missing" never reaches the client.
Switch the locale per request in your controller:
class McpController < ApplicationController
include ActionModelContext::Server
around_action :switch_locale
private
def switch_locale(&) = I18n.with_locale(request.headers['Accept-Language'].presence_in(%w[en fr]) || :en, &)
end
This sample only matches a header that is exactly en or fr. A real Accept-Language header such as fr-FR,fr;q=0.9,en;q=0.8 has to be parsed first.
Do not mix languages across tools in one server. Descriptions in several languages degrade tool selection by the model (see modelcontextprotocol issue #1395). Serve one consistent locale per client.
Testing
ActionModelContext::TestHelper runs a tool through a real MCP::Server, so argument validation and error handling behave as in production:
require 'action_model_context/test_helper'
RSpec.describe EchoTool do
include ActionModelContext::TestHelper
it 'repeats the message' do
result = call_tool(EchoTool, message: 'hi', times: 2)
expect(result.text).to eq('hihi')
expect(result).not_to be_error
end
it 'passes the context' do
expect(call_tool(WhoAmITool, context: { user: 'ada' }).structured_content).to eq(user: 'ada')
end
end
call_tool(tool_class, context: {}, **arguments) returns a result with text, structured_content and error?. An unexpected exception raised by the tool is re-raised in the test.
License
MIT.