Toolchest

Research preview — APIs may change, some features aren't great yet, and it is not yet recommended for production use (i'm still gonna though!). Feedback and bug reports are welcome.

Every Ruby MCP library I could find is Ruby from an MCP perspective. Toolchest is MCP from a Rails perspective.

Toolboxes are controllers, tools are actions.

Why

Every Ruby MCP gem I found treats tools as isolated service objects. One file per tool, set_model reimplemented in every call method, a whole DSL that exists nowhere else in your app. Four tools for orders means four files.

A tool call is a controller action. Authenticated request, named action, structured params, do the thing, return JSON. Rails figured this out twenty years ago.

Toolboxes are controllers. Tools are actions. before_action works. rescue_from works. Views are views.

Quick start

bundle add toolchest jb
rails g toolchest:install --auth=none
rails g toolchest Orders show create
rails s
# point your MCP client at http://localhost:3000/mcp

Toolboxes

app/toolboxes/. They work like controllers because they basically are.

# app/toolboxes/application_toolbox.rb
class ApplicationToolbox < Toolchest::Toolbox
  helper_method :current_user

  def current_user = auth&.resource_owner

  rescue_from ActiveRecord::RecordNotFound do |e|
    render_error "Couldn't find that #{e.model.downcase}"
  end
end

This is your ApplicationController. auth returns a Toolchest::AuthContext with .resource_owner (whatever your authenticate block returns), .scopes (always from the token), and .token (the raw record). Define current_user as a convenience and expose it to views with helper_method, add shared error handling, include your gems.

# app/toolboxes/orders_toolbox.rb
class OrdersToolbox < ApplicationToolbox
  default_param :order_id, :string, "The order ID", except: [:create, :search]
  before_action :set_order, except: [:create, :search]

  tool "Look up an order by ID" do
  end
  def show
    # implicit render: app/views/toolboxes/orders/show.json.jb
  end

  tool "Update order status" do
    param :status, :string, "New status", enum: %w[pending confirmed shipped]
    param :tracking, :string, "Tracking number", optional: true
  end
  def update
    if @order.update(params.permit(:status, :tracking).to_h)
      render :show
    else
      render_errors @order
    end
  end

  tool "Create a new order" do
    param :customer_id, :string, "Customer"
    param :items, [:object], "Line items" do
      param :product_id, :string, "Product SKU"
      param :quantity, :integer, "How many", default: 1
    end
  end
  def create
    @order = Order.new(params.permit(:customer_id).to_h)
    if @order.save
      render :show
      suggests :show, "Get the full order details"
    else
      render_errors @order
    end
  end

  private

  def set_order
    @order = Order.find(params[:order_id])
  end
end

halt

Stop execution early, usually in a before_action:

before_action :require_admin!

def require_admin!
  halt error: "forbidden" unless current_user.admin?
end

Rendering

render :show                    # app/views/toolboxes/{toolbox}/show.json.jb
render "shared/status"          # explicit template path
render json: { ok: true }       # inline, no view file
render text: "done"             # plain text
# no render call = implicit render of current action name
render_error "Something broke"  # MCP error (isError: true), string
render_errors @order            # MCP error from ActiveModel errors

Views

app/views/toolboxes/, using jb or jbuilder:

# app/views/toolboxes/orders/show.json.jb
{
  id: @order.id,
  status: @order.status,
  customer: @order.customer.name,
  total: @order.total.to_f
}

render :show after mutations. Most toolboxes need one or two views.

View helpers

Toolbox methods aren't available in views by default — views only get instance variables. Use helper_method to expose methods, same as a Rails controller:

class ApplicationToolbox < Toolchest::Toolbox
  helper_method :current_user, :admin?

  def current_user = auth&.resource_owner
  def admin? = current_user&.admin?
end

Now current_user and admin? work in any view:

# app/views/toolboxes/orders/show.json.jb
{
  id: @order.id,
  status: @order.status,
  viewer: current_user.name,
  can_edit: admin?
}

Include entire modules with helper:

class ApplicationToolbox < Toolchest::Toolbox
  helper FormattingHelper
end

Both helper_method and helper inherit through the toolbox hierarchy — define them in ApplicationToolbox and every toolbox gets them.

Resources and prompts

Supported, though most toolboxes won't need them.

class OrdersToolbox < ApplicationToolbox
  resource "orders://schema", name: "Order schema", description: "Order field structure" do
    { fields: Order.column_names, statuses: Order::STATUSES }
  end

  resource "orders://{order_id}", name: "Order details", description: "Full order by ID" do |order_id:|
    Order.find(order_id).as_json(include: :items)
  end

  prompt "debug-order", description: "Investigate order issues",
    arguments: { order_id: { type: :string, required: true } } do |order_id:|
    order = Order.find(order_id)
    [{ role: "user", content: "Debug this order:\n#{order.to_json}" }]
  end
end

Suggests

Tell the LLM what to call next:

suggests :show, "Call orders_show for full details"

Params

Like ActionController::Parameters:

params[:order_id]
params.require(:order_id)        # raises Toolchest::ParameterMissing if absent
params.permit(:status, :tracking)
params.slice(:status)
params.except(:internal_field)

Params are automatically filtered to only keys declared in the tool's param block. Undeclared keys get dropped.

Sampling

Ask the client's LLM to do work from inside a tool action:

tool "Summarize an order" do
  param :order_id, :string, "Order ID"
end
def summarize
  @order = Order.find(params[:order_id])
  summary = mcp_sample("Summarize this order for a support agent", context: @order.to_json)
  render text: summary
end

Block form for more control:

summary = mcp_sample do |s|
  s.system "You are a support analyst"
  s.user "Analyze this order:\n#{@order.to_json}"
  s.max_tokens 500
  s.temperature 0.3
end

Raises Toolchest::Error if the client doesn't support sampling. Handle it with rescue_from in your toolbox.

Progress

Report progress during long-running actions. Clients that support it show a progress bar:

tool "Import customers" do
  param :file_url, :string, "CSV URL"
end
def import
  rows = CSV.parse(download(params[:file_url]))
  rows.each_with_index do |row, i|
    Customer.create!(row.to_h)
    mcp_progress i + 1, total: rows.size, message: "Importing #{row[:name]}"
  end
  render text: "Imported #{rows.size} customers"
end

No-op if the client doesn't send a progress token.

Annotations

Tool annotations tell the client about a tool's behavior. They're derived automatically from access::

tool "Show order", access: :read do    # → readOnlyHint: true, destructiveHint: false
end

tool "Delete order", access: :write do  # → readOnlyHint: false, destructiveHint: true
end

Override or add hints with annotations::

tool "Export data", access: :read, annotations: { openWorldHint: true } do
end

Logging

mcp_log :info, "Processing order #{@order.id}"

Completion

If a param has enum:, those values automatically power MCP's completion/complete. Clients that support autocomplete get it for free.

Server instructions

Tell the LLM how to use your tools:

Toolchest.configure do |config|
  config.server_instructions = "You are a support agent. Always look up the customer before modifying orders."
end

This shows up in the MCP initialize response. server_name and server_description are also available.

Auth

Three built-in strategies, or bring your own. Default is :none.

:token

Bearer tokens. In dev, set env vars and you're done:

TOOLCHEST_TOKEN=tcht_dev_secret
TOOLCHEST_TOKEN_OWNER=user:1
TOOLCHEST_TOKEN_SCOPES="orders:read orders:write"  # optional

For production, run the migration and manage with rake:

rails g toolchest:auth token
rails db:migrate
rails toolchest:token:generate OWNER=user:1 NAME="claude desktop"
rails toolchest:token:list
rails toolchest:token:revoke TOKEN=tcht_...
Toolchest.configure do |config|
  config.auth = :token

  config.authenticate do |token|
    User.find(token.owner_id)
  end
end

authenticate resolves the token to a user (or anything). The return value becomes auth.resource_owner in your toolboxes. Scopes are preserved from the token automatically — you can't lose them here.

:oauth

MCP clients like Claude Desktop and Cursor need OAuth 2.1 with PKCE and Dynamic Client Registration. Toolchest ships a built-in OAuth provider so you can get this working without wiring up Doorkeeper.

It's intentionally minimal — enough to make MCP auth work, completely isolated from the rest of your app. Its tables are all toolchest_-prefixed, it doesn't know Doorkeeper exists. It will not break your existing OAuth setup.

If you already have an OAuth provider, you probably want :token instead and validate your own tokens in the authenticate block. The built-in provider exists so you don't have to figure all that out before you can connect Claude Desktop.

rails g toolchest:install --auth=oauth
rails db:migrate
Toolchest.configure do |config|
  config.auth = :oauth
  config. = "/login"

  config.current_user_for_oauth do |request|
    # return the logged-in user for the consent screen, or nil to redirect
    request.env["warden"]&.user  # devise example
  end

  config.authenticate do |token|
    User.find(token.resource_owner_id)
  end
end

authenticate resolves the token to auth.resource_owner. Scopes come from the token and are never lost, even if you return a plain User.

You also need toolchest_oauth in your routes for .well-known discovery:

# config/routes.rb
mount Toolchest.app => "/mcp"
toolchest_oauth

This adds the endpoints MCP clients expect:

/.well-known/oauth-authorization-server   ← discovery (app root)
/.well-known/oauth-protected-resource     ← discovery (app root)
/mcp/oauth/authorize                      ← consent screen
/mcp/oauth/token                          ← token exchange
/mcp/oauth/register                       ← dynamic client registration

Customize the consent view: rails g toolchest:consent

There's a built-in "connected applications" page at /mcp/oauth/authorized_applications where users can revoke access. Link to it from your account settings.

You can also query tokens directly:

Toolchest::OauthAccessToken.revoke_all_for(app, user.id)
Toolchest::OauthAccessGrant.revoke_all_for(app, user.id)
app.destroy  # cascades to all grants and tokens

Controller behavior

The consent screen and authorized applications page inherit from your app's ApplicationController by default — so they get your auth, layout, nav, etc. Route helpers like root_path in your layout work automatically (Toolchest delegates unresolved _path/_url helpers to main_app).

If you don't want host app behavior on these pages:

# config/initializers/toolchest.rb
Toolchest.base_controller = "ActionController::Base"

To disable the route helper delegation:

Toolchest.delegate_route_helpers = false

API-only endpoints (token exchange, metadata, dynamic registration) always use ActionController::API regardless of this setting.

Custom

If the built-in strategies don't fit, pass any object that responds to #authenticate(request):

Toolchest.configure do |config|
  config.auth = WardenAuth.new
end
class WardenAuth
  def authenticate(request)
    user = request.env["warden"]&.user
    return nil unless user
    Toolchest::AuthContext.new(resource_owner: user, scopes: [], token: nil)
  end
end

Custom strategies return an AuthContext (or nil for unauthenticated). If you return something else, auth will be that object directly — but scope filtering only works with AuthContext.

You can inherit from Toolchest::Auth::Base to get extract_bearer_token for free:

class ApiKeyAuth < Toolchest::Auth::Base
  def authenticate(request)
    key = request.env["HTTP_X_API_KEY"]
    ApiKey.active.find_by(key: key)&.owner
  end
end

Scopes

Scopes work with both :token and :oauth auth. Define them in your config:

config.scopes = {
  "orders:read"  => "View order details",
  "orders:write" => "Create and modify orders",
  "users:read"   => "View user profiles"
}

The pattern is {toolbox}:{access}. Toolchest maps tools to scopes automatically: actions named show, index, list, or search are :read, everything else is :write. A client granted orders:read sees orders_show and orders_search but not orders_cancel. orders:write gets everything. orders with no suffix also gets everything.

With OAuth, scopes show up on the consent screen and filter tools/list by what was granted. With token auth, set scopes via TOOLCHEST_TOKEN_SCOPES (env var) or the scopes column on the token record.

Override when the convention is wrong:

tool "Export data", access: :read do
end
def export
  # ...
end

Turn it off: config.filter_tools_by_scope = false

Per-tool scope override

The convention derives scopes from the toolbox name — OrdersToolbox tools require orders:*. When a tool doesn't fit its toolbox's scope boundary, override it:

class TicketsToolbox < ApplicationToolbox
  tool "List tickets" do
  end
  def list = # ... requires tickets:read (convention)

  # Only tokens with the "admin" scope can see or call this tool
  tool "Move ticket", scope: "admin" do
    param :status, :string, "New status"
  end
  def move = # ...

  # Either "admin" OR "superuser" grants access
  tool "Escalate ticket", scope: ["admin", "superuser"] do
    param :id, :string, "Ticket ID"
  end
  def escalate = # ...
end

scope: replaces the convention entirely for that tool — tickets:write won't grant access to a tool with scope: "admin". The token must include the literal scope string. Enforced on both tools/list (visibility) and tools/call (execution).

Optional scopes (checkboxes)

By default, the consent screen is all-or-nothing — approve all requested scopes or deny. Enable optional_scopes and users get checkboxes:

config.optional_scopes = true

All scopes start checked. Users uncheck what they don't want. The token only gets the scopes the user approved. That's it — no other config needed.

Layer on more control when you need it:

# These scopes are always granted (checked + disabled on the consent screen)
config.required_scopes = ["orders:read"]

# Per-user gating — hide scopes from users who shouldn't grant them
config.allowed_scopes_for do |user, requested_scopes|
  user.admin? ? requested_scopes : requested_scopes - ["admin:write"]
end

Scopes hidden by allowed_scopes_for never appear on the consent screen and can't be granted even if the POST is tampered with.

Blocking users from linking

Gate who can link an MCP client to their account with authorize_link. If it returns falsy, the user is redirected with access_denied before they ever see a consent screen.

config.authorize_link do |user|
  !user.banned?
end

Only applies when using auth: :oauth. For auth: :token or custom strategies, gate access in your own authentication layer.

Multi-mount

Separate MCP endpoints, different auth, different toolboxes:

Toolchest.configure do |config|
  config.auth = :oauth
  config.toolbox_module = "Public"
end

Toolchest.configure(:admin) do |config|
  config.auth = :token
  config.toolbox_module = "Admin"
end
# config/routes.rb
mount Toolchest.app => "/mcp"
mount Toolchest.app(:admin) => "/admin-mcp"
toolchest_oauth

Namespace your toolboxes under modules (Admin::OrdersToolbox, Public::OrdersToolbox) and they route to the right mount.

With multiple OAuth mounts, .well-known discovery uses the path suffix per RFC 8414 — e.g. /.well-known/oauth-authorization-server/admin-mcp. Some clients (notably Cursor) hit the bare path without a suffix. Set default_mount so Toolchest knows which mount to use:

toolchest_oauth default_mount: :default

With a single OAuth mount this isn't needed.

Tool naming

config.tool_naming = :underscored  # orders_show (default)
config.tool_naming = :dotted       # orders.show
config.tool_naming = :slashed      # orders/show
config.tool_naming = ->(prefix, method) { "#{prefix}__#{method}" }

Per-tool: tool "description", name: "custom_name"

Generators

rails g toolchest:install             # initializer, app toolbox, routes
rails g toolchest Orders show create  # toolbox + views + spec
rails g toolchest Admin::Orders show  # namespaced
rails g toolchest:auth oauth          # add auth migration + views
rails g toolchest:consent             # eject consent view
rails g toolchest:oauth_views         # eject all OAuth views + controllers
rails g toolchest:skills              # install Claude Code slash commands

Introspection

rails toolchest:tools

Testing

RSpec.describe OrdersToolbox, type: :toolbox do
  it "shows an order" do
    call_tool "orders_show", params: { order_id: "123" }, as: user
    expect(tool_response).to be_success
    expect(tool_response.text).to include("shipped")
  end

  it "returns errors for invalid updates" do
    call_tool "orders_update", params: { order_id: "123", status: "pending" }, as: user
    expect(tool_response).to be_error
  end

  it "suggests next tool after create" do
    call_tool "orders_create", params: { customer_id: "c1" }, as: user
    expect(tool_response).to suggest("orders_show")
  end
end

require "toolchest/rspec" in your rails_helper.rb.

Security notes

  • Rate limiting: Toolchest doesn't include rate limiting. Use rack-attack or your reverse proxy to protect token and registration endpoints.
  • HTTPS: OAuth endpoints should always run behind TLS in production.

Internals

Transport is the MCP Ruby SDK (mcp gem).

OAuth provider is cribbed from Doorkeeper. Same table layout, same controller shapes. Not a dependency, just stole the design.

For agents

If you're implementing this with an agent (or you're the agent reading this), consider the contents of LLMS.txt.

Claude Code skills

Install skills for Claude Code that know how to work with toolchest:

rails g toolchest:skills

This gives you /add-toolbox, /add-tool, and /toolchest-auth slash commands.

Requirements

  • Ruby >= 3.2
  • Rails >= 7.0
  • jb (recommended) or jbuilder

Disclaimer

This is slop by LLMs for LLMs and only a fool would use it in production. However, I am a fool. No implied warranty of any kind, if you trust this and it explodes you please cry to someone else.

License

MIT