An opinionated toolkit for building account-scoped, read-only MCP servers on
top of the official mcp gem.
It extracts the shared MCP-server framework that several apps grew independently into one versioned, standalone library, so a new app can add an MCP server in ~20 lines. It ships:
- a Streamable-HTTP transport (POST/GET/DELETE/health, SSE-on-
Accept,202-for-notifications) as an includable controller concern; - cache-backed sessions (
Mcp-Session-Id, sliding TTL) that survive across Puma workers; - central-app token introspection in two roles — be the authority (authenticate local tokens + answer introspection) or a satellite (validate forwarded tokens against the central app);
- a registry-driven "generic tools over N resources" dispatcher
(
list/get/resources/resource_schema) wrapping the officialmcpgem's JSON-RPC core; - an injectable serializer DSL (the default base, or your own — e.g. an existing app serializer).
The JSON-RPC protocol, version negotiation, and error envelopes are delegated to
the official mcp gem; this toolkit owns everything around it.
# Gemfile
gem "mcp_toolkit"bundle installA typical topology is one central app responsible for auth and N
satellites that expose their own resources and validate forwarded tokens by
introspecting against the central app. mcp_toolkit makes both roles trivial.
Everything is driven by a single config object:
McpToolkit.configure do |c|
# ...
end(MCPToolkit is an alias — MCPToolkit.configure { ... } works identically.)
A satellite exposes read-only resources and trusts no token locally: it introspects each forwarded bearer token against the central app.
1. Configure (config/initializers/mcp_toolkit.rb):
McpToolkit.configure do |c|
c.server_name = "bsa-notifications-mcp"
c.server_instructions = "Read-only access to this account's notifications domain."
# --- satellite auth ---
c.auth_role = :satellite
c.central_app_url = ENV.fetch("MCP_CENTRAL_APP_URL") # POSTs <url>/mcp/tokens/introspect
# The scope every tool requires, declared ONCE for all resources. A resource
# can override it per-resource (see below). Omit entirely for "no scope
# required". Whether a scope is required is PER TOOL — there is no app-wide
# permission flag.
c.registry.default_required_permissions_scope "notifications__read"
# Map the central account id to this app's LOCAL scope root (an Account here).
c.account_resolver = ->(synced_account_id) { Account.find_by(synced_id: synced_account_id) }
# Share sessions/introspection across workers.
c.cache_store = Rails.cache
# The engine's controller inherits ActionController::Base by default; point it
# at ApplicationController if your stack needs helper_method (e.g. logstasher).
c.parent_controller = "ApplicationController"
end2. Register resources (same initializer, wrapped in to_prepare so they
refresh on reload). Every scope block MUST return a relation already rooted on
the resolved scope root — this is the single tenancy chokepoint:
Rails.application.config.to_prepare do
McpToolkit.registry.reset!
McpToolkit.registry.register(:notifications) do
model Notification
serializer Mcp::NotificationSerializer # your serializer (see below)
description "Email notification templates + their scheduling rules."
scope(&:notifications) # account.notifications
end
McpToolkit.registry.register(:scheduled_notifications) do
model ScheduledNotification
serializer Mcp::ScheduledNotificationSerializer
description "Scheduled mailings."
# Expose a public filter key that maps to a synced storage column:
filterable booking_id: :synced_booking_id
# Override the registry default scope for just this resource (optional):
required_permissions_scope "notifications__read"
scope { |account| ScheduledNotification.where(synced_account_id: account.synced_id) }
end
endEach resource's effective required scope is its own required_permissions_scope
if declared, else the registry's default_required_permissions_scope, else none.
3. Mount the transport — one line. The gem ships the engine and the controller, so a satellite writes no routes and no controller of its own:
# config/routes.rb
mount McpToolkit::Engine => "/mcp"That yields POST/GET/DELETE /mcp + GET /mcp/health exactly as a hand-rolled
satellite did. The four generic tools (resources, resource_schema, get,
list) are now live over Streamable-HTTP, each call authenticated by
introspecting the forwarded token and scoped to the resolved account.
Prefer to keep your own controller? The transport is also a standalone concern —
include McpToolkit::Transport::ControllerMethodsin a controller and route the four endpoints yourself. The engine is purely additive.
The authority authenticates plaintext tokens locally and answers the introspection requests satellites send.
1. Configure the local token lookup (your McpToken.authenticate equivalent):
McpToolkit.configure do |c|
c.auth_role = :authority
c.token_authenticator = ->(plaintext) { McpToken.authenticate(plaintext) }
c.cache_store = Rails.cache
endThe token object your authenticator returns must respond to:
kind (:accounts_user | :user), account_id, account_ids, expires_at
(an #iso8601-able time or nil), and scopes (an array of <app>__<action>
scopes; [] = no scopes). Optionally touch_last_used!. A typical app token
model (e.g. McpToken) fits.
2. Expose the introspection endpoint the satellites call:
class Mcp::TokensController < ActionController::API
def introspect
token = McpToolkit::Auth::Authority.authenticate(extract_token)
return render(json: McpToolkit::Auth::Authority.invalid_payload, status: :unauthorized) unless token
render json: McpToolkit::Auth::Authority.introspection_payload(token)
end
private
def extract_token
auth = request.headers["Authorization"]
return auth.sub("Bearer ", "") if auth&.start_with?("Bearer ")
request.headers["X-MCP-Token"].presence || params[:token].presence
end
end# config/routes.rb
post "mcp/tokens/introspect", to: "mcp/tokens#introspect"The payload introspection_payload emits is exactly the contract the satellite's
McpToolkit::Auth::Introspection parses — the two roles interoperate out of the
box. (An app can be both: a central app that also exposes its own tools just
sets the authority config and registers resources + the transport controller.)
The registry takes a serializer class per resource. The gem ships a default
DSL base (McpToolkit::Serializer::Base), but the only thing the executors
require is that a serializer responds to two class methods:
serializer.serialize_one(record, scope:)
# => Hash (a single record's shape), or nil for a nil record
serializer.serialize_collection(records, scope:, total_count:, limit:, offset:)
# => { <root_key> => [ <record_hash>, ... ], meta: { total_count:, limit:, offset: } }Any class satisfying that contract slots in — including an app's existing serializers. Register it directly:
Sparse fieldsets. Both methods also accept an optional
fields:keyword (an array of attribute/relationship names) soget/listcan return a subset of a record's shape. Honoring it natively — the bundled base does — skips computing the unselected members; a serializer that ignores it still works, because the toolkit prunes its output to the requestedfieldsinstead. Omittingfields:(the default) returns the full shape, so this is fully backward-compatible.
McpToolkit.registry.register(:bookings) do
model Booking
serializer BookingSerializer # your existing serializer
scope { |account| account.bookings }
endclass Mcp::NotificationSerializer < McpToolkit::Serializer::Base
attributes :id, :name, :active, :created_at, :updated_at
translates :subject, :template_html # Globalize-backed { locale => value }
has_one :account, foreign_key: :synced_account_id
has_one :mail_layout
has_many :scheduled_notifications
endIt emits declared attributes as symbol keys (in declaration order), a sorted
string-keyed "links" hash (ids / {id:,type:} for polymorphic / sorted arrays
for has_many), and iso8601(6) timestamps. To power the resource_schema
discovery tool, a custom serializer may also expose declared_attributes /
declared_associations; this is optional (the base provides them).
| Setting | Default | Purpose |
|---|---|---|
server_name / server_version / server_instructions |
"mcp-server" / "1.0.0" / nil |
advertised on initialize |
serializer_base |
McpToolkit::Serializer::Base |
the default base to subclass |
auth_role |
:satellite |
:satellite or :authority |
central_app_url |
nil |
satellite: base URL of the auth authority |
introspect_path |
"/mcp/tokens/introspect" |
satellite: appended to central_app_url |
introspection_cache_ttl |
45 |
seconds to cache introspection results |
introspection_timeout |
10 |
HTTP timeout (s) for the introspection call |
account_resolver |
identity | maps the central account id → local scope root |
token_authenticator |
nil |
authority: ->(plaintext) { token_or_nil } |
cache_store |
MemoryStore |
sessions + introspection cache (set to Rails.cache) |
session_ttl |
3600 |
session sliding TTL (s) |
protocol_version |
nil (negotiate) |
pin an MCP protocol version |
parent_controller |
"ActionController::Base" |
superclass of the engine's ServerController (set to "ApplicationController" for helper_method compat) |
account_meta_key |
"mcp-toolkit/account-id" |
_meta key a superuser uses to pin the account |
account_id_header |
"X-MCP-Account-ID" |
header fallback for the account selector |
McpToolkit.configure { |c| ... },McpToolkit.config,McpToolkit.registry,McpToolkit.reset_config!McpToolkit::Registry#register(name) { ... }(DSL:model,serializer,scope,description,filterable,required_permissions_scope) +#default_required_permissions_scopeMcpToolkit::Serializer::Base(DSL:attributes,has_one,has_many,translates)McpToolkit::Server.build(server_context:, config:, extra_tools:)McpToolkit::Engine(mountable;mount McpToolkit::Engine => "/mcp") +McpToolkit::ServerController(its controller; parent viaparent_controller)McpToolkit::Transport::ControllerMethods(standalone controller concern; overridemcp_config/mcp_extra_tools)McpToolkit::SessionMcpToolkit::Auth::Introspection/Authenticator(satellite),McpToolkit::Auth::Authority(authority)McpToolkit::Errors::{InvalidParams, Unauthorized, ConfigurationError}
The gateway / upstream-aggregation layer (a central app's Mcp::Upstreams*) is
out of scope — it's core-only and ships to no satellite. This toolkit is for
servers that expose tools, not for aggregating other servers.
bin/setup
bundle exec rspecAvailable as open source under the terms of the MIT License.