toolchest 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/LLMS.txt +484 -0
- data/README.md +572 -0
- data/app/controllers/toolchest/oauth/authorizations_controller.rb +152 -0
- data/app/controllers/toolchest/oauth/authorized_applications_controller.rb +68 -0
- data/app/controllers/toolchest/oauth/metadata_controller.rb +68 -0
- data/app/controllers/toolchest/oauth/registrations_controller.rb +53 -0
- data/app/controllers/toolchest/oauth/tokens_controller.rb +98 -0
- data/app/models/toolchest/oauth_access_grant.rb +66 -0
- data/app/models/toolchest/oauth_access_token.rb +71 -0
- data/app/models/toolchest/oauth_application.rb +26 -0
- data/app/models/toolchest/token.rb +51 -0
- data/app/views/toolchest/oauth/authorizations/new.html.erb +45 -0
- data/app/views/toolchest/oauth/authorized_applications/index.html.erb +34 -0
- data/config/routes.rb +18 -0
- data/lib/generators/toolchest/auth_generator.rb +55 -0
- data/lib/generators/toolchest/consent_generator.rb +34 -0
- data/lib/generators/toolchest/install_generator.rb +70 -0
- data/lib/generators/toolchest/oauth_views_generator.rb +51 -0
- data/lib/generators/toolchest/skills_generator.rb +356 -0
- data/lib/generators/toolchest/templates/application_toolbox.rb.tt +10 -0
- data/lib/generators/toolchest/templates/create_toolchest_oauth.rb.tt +39 -0
- data/lib/generators/toolchest/templates/create_toolchest_tokens.rb.tt +16 -0
- data/lib/generators/toolchest/templates/initializer.rb.tt +41 -0
- data/lib/generators/toolchest/templates/oauth_authorize.html.erb.tt +48 -0
- data/lib/generators/toolchest/templates/toolbox.rb.tt +19 -0
- data/lib/generators/toolchest/templates/toolbox_spec.rb.tt +23 -0
- data/lib/generators/toolchest/toolbox_generator.rb +44 -0
- data/lib/toolchest/app.rb +47 -0
- data/lib/toolchest/auth/base.rb +15 -0
- data/lib/toolchest/auth/none.rb +7 -0
- data/lib/toolchest/auth/oauth.rb +28 -0
- data/lib/toolchest/auth/token.rb +73 -0
- data/lib/toolchest/auth_context.rb +13 -0
- data/lib/toolchest/configuration.rb +82 -0
- data/lib/toolchest/current.rb +7 -0
- data/lib/toolchest/endpoint.rb +13 -0
- data/lib/toolchest/engine.rb +95 -0
- data/lib/toolchest/naming.rb +31 -0
- data/lib/toolchest/oauth/routes.rb +25 -0
- data/lib/toolchest/param_definition.rb +69 -0
- data/lib/toolchest/parameters.rb +71 -0
- data/lib/toolchest/rack_app.rb +114 -0
- data/lib/toolchest/renderer.rb +88 -0
- data/lib/toolchest/router.rb +277 -0
- data/lib/toolchest/rspec.rb +61 -0
- data/lib/toolchest/sampling_builder.rb +38 -0
- data/lib/toolchest/tasks/toolchest.rake +123 -0
- data/lib/toolchest/tool_builder.rb +19 -0
- data/lib/toolchest/tool_definition.rb +58 -0
- data/lib/toolchest/toolbox.rb +312 -0
- data/lib/toolchest/version.rb +3 -0
- data/lib/toolchest.rb +89 -0
- metadata +122 -0
data/README.md
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
# Toolchest
|
|
2
|
+
|
|
3
|
+
> **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.
|
|
4
|
+
|
|
5
|
+
Every Ruby MCP library I could find is Ruby from an MCP perspective. Toolchest is MCP from a Rails perspective.
|
|
6
|
+
|
|
7
|
+
Toolboxes are controllers, tools are actions.
|
|
8
|
+
|
|
9
|
+
## Why
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
Toolboxes are controllers. Tools are actions. `before_action` works. `rescue_from` works. Views are views.
|
|
16
|
+
|
|
17
|
+
## Quick start
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
bundle add toolchest jb
|
|
21
|
+
rails g toolchest:install --auth=none
|
|
22
|
+
rails g toolchest Orders show create
|
|
23
|
+
rails s
|
|
24
|
+
# point your MCP client at http://localhost:3000/mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Toolboxes
|
|
28
|
+
|
|
29
|
+
`app/toolboxes/`. They work like controllers because they basically are.
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# app/toolboxes/application_toolbox.rb
|
|
33
|
+
class ApplicationToolbox < Toolchest::Toolbox
|
|
34
|
+
def current_user = auth&.resource_owner
|
|
35
|
+
|
|
36
|
+
rescue_from ActiveRecord::RecordNotFound do |e|
|
|
37
|
+
render_error "Couldn't find that #{e.model.downcase}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
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, add shared error handling, include your gems.
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# app/toolboxes/orders_toolbox.rb
|
|
46
|
+
class OrdersToolbox < ApplicationToolbox
|
|
47
|
+
default_param :order_id, :string, "The order ID", except: [:create, :search]
|
|
48
|
+
before_action :set_order, except: [:create, :search]
|
|
49
|
+
|
|
50
|
+
tool "Look up an order by ID" do
|
|
51
|
+
end
|
|
52
|
+
def show
|
|
53
|
+
# implicit render: app/views/toolboxes/orders/show.json.jb
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
tool "Update order status" do
|
|
57
|
+
param :status, :string, "New status", enum: %w[pending confirmed shipped]
|
|
58
|
+
param :tracking, :string, "Tracking number", optional: true
|
|
59
|
+
end
|
|
60
|
+
def update
|
|
61
|
+
if @order.update(params.permit(:status, :tracking).to_h)
|
|
62
|
+
render :show
|
|
63
|
+
else
|
|
64
|
+
render_errors @order
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
tool "Create a new order" do
|
|
69
|
+
param :customer_id, :string, "Customer"
|
|
70
|
+
param :items, [:object], "Line items" do
|
|
71
|
+
param :product_id, :string, "Product SKU"
|
|
72
|
+
param :quantity, :integer, "How many", default: 1
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
def create
|
|
76
|
+
@order = Order.new(params.permit(:customer_id).to_h)
|
|
77
|
+
if @order.save
|
|
78
|
+
render :show
|
|
79
|
+
suggests :show, "Get the full order details"
|
|
80
|
+
else
|
|
81
|
+
render_errors @order
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def set_order
|
|
88
|
+
@order = Order.find(params[:order_id])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### halt
|
|
94
|
+
|
|
95
|
+
Stop execution early, usually in a `before_action`:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
before_action :require_admin!
|
|
99
|
+
|
|
100
|
+
def require_admin!
|
|
101
|
+
halt error: "forbidden" unless current_user.admin?
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Rendering
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
render :show # app/views/toolboxes/{toolbox}/show.json.jb
|
|
109
|
+
render "shared/status" # explicit template path
|
|
110
|
+
render json: { ok: true } # inline, no view file
|
|
111
|
+
render text: "done" # plain text
|
|
112
|
+
# no render call = implicit render of current action name
|
|
113
|
+
render_error "Something broke" # MCP error (isError: true), string
|
|
114
|
+
render_errors @order # MCP error from ActiveModel errors
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Views
|
|
118
|
+
|
|
119
|
+
`app/views/toolboxes/`, using [jb](https://github.com/amatsuda/jb) or jbuilder:
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# app/views/toolboxes/orders/show.json.jb
|
|
123
|
+
{
|
|
124
|
+
id: @order.id,
|
|
125
|
+
status: @order.status,
|
|
126
|
+
customer: @order.customer.name,
|
|
127
|
+
total: @order.total.to_f
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`render :show` after mutations. Most toolboxes need one or two views.
|
|
132
|
+
|
|
133
|
+
### Resources and prompts
|
|
134
|
+
|
|
135
|
+
Supported, though most toolboxes won't need them.
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
class OrdersToolbox < ApplicationToolbox
|
|
139
|
+
resource "orders://schema", name: "Order schema", description: "Order field structure" do
|
|
140
|
+
{ fields: Order.column_names, statuses: Order::STATUSES }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
resource "orders://{order_id}", name: "Order details", description: "Full order by ID" do |order_id:|
|
|
144
|
+
Order.find(order_id).as_json(include: :items)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
prompt "debug-order", description: "Investigate order issues",
|
|
148
|
+
arguments: { order_id: { type: :string, required: true } } do |order_id:|
|
|
149
|
+
order = Order.find(order_id)
|
|
150
|
+
[{ role: "user", content: "Debug this order:\n#{order.to_json}" }]
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Suggests
|
|
156
|
+
|
|
157
|
+
Tell the LLM what to call next:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
suggests :show, "Call orders_show for full details"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Params
|
|
164
|
+
|
|
165
|
+
Like `ActionController::Parameters`:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
params[:order_id]
|
|
169
|
+
params.require(:order_id) # raises Toolchest::ParameterMissing if absent
|
|
170
|
+
params.permit(:status, :tracking)
|
|
171
|
+
params.slice(:status)
|
|
172
|
+
params.except(:internal_field)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Params are automatically filtered to only keys declared in the tool's `param` block. Undeclared keys get dropped.
|
|
176
|
+
|
|
177
|
+
### Sampling
|
|
178
|
+
|
|
179
|
+
Ask the client's LLM to do work from inside a tool action:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
tool "Summarize an order" do
|
|
183
|
+
param :order_id, :string, "Order ID"
|
|
184
|
+
end
|
|
185
|
+
def summarize
|
|
186
|
+
@order = Order.find(params[:order_id])
|
|
187
|
+
summary = mcp_sample("Summarize this order for a support agent", context: @order.to_json)
|
|
188
|
+
render text: summary
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Block form for more control:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
summary = mcp_sample do |s|
|
|
196
|
+
s.system "You are a support analyst"
|
|
197
|
+
s.user "Analyze this order:\n#{@order.to_json}"
|
|
198
|
+
s.max_tokens 500
|
|
199
|
+
s.temperature 0.3
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Raises `Toolchest::Error` if the client doesn't support sampling. Handle it with `rescue_from` in your toolbox.
|
|
204
|
+
|
|
205
|
+
### Progress
|
|
206
|
+
|
|
207
|
+
Report progress during long-running actions. Clients that support it show a progress bar:
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
tool "Import customers" do
|
|
211
|
+
param :file_url, :string, "CSV URL"
|
|
212
|
+
end
|
|
213
|
+
def import
|
|
214
|
+
rows = CSV.parse(download(params[:file_url]))
|
|
215
|
+
rows.each_with_index do |row, i|
|
|
216
|
+
Customer.create!(row.to_h)
|
|
217
|
+
mcp_progress i + 1, total: rows.size, message: "Importing #{row[:name]}"
|
|
218
|
+
end
|
|
219
|
+
render text: "Imported #{rows.size} customers"
|
|
220
|
+
end
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
No-op if the client doesn't send a progress token.
|
|
224
|
+
|
|
225
|
+
### Annotations
|
|
226
|
+
|
|
227
|
+
Tool annotations tell the client about a tool's behavior. They're derived automatically from `access:`:
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
tool "Show order", access: :read do # → readOnlyHint: true, destructiveHint: false
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
tool "Delete order", access: :write do # → readOnlyHint: false, destructiveHint: true
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Override or add hints with `annotations:`:
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
tool "Export data", access: :read, annotations: { openWorldHint: true } do
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Logging
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
mcp_log :info, "Processing order #{@order.id}"
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Completion
|
|
251
|
+
|
|
252
|
+
If a param has `enum:`, those values automatically power MCP's `completion/complete`. Clients that support autocomplete get it for free.
|
|
253
|
+
|
|
254
|
+
### Server instructions
|
|
255
|
+
|
|
256
|
+
Tell the LLM how to use your tools:
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
Toolchest.configure do |config|
|
|
260
|
+
config.server_instructions = "You are a support agent. Always look up the customer before modifying orders."
|
|
261
|
+
end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
This shows up in the MCP initialize response. `server_name` and `server_description` are also available.
|
|
265
|
+
|
|
266
|
+
## Auth
|
|
267
|
+
|
|
268
|
+
Three built-in strategies, or bring your own. Default is `:none`.
|
|
269
|
+
|
|
270
|
+
### :token
|
|
271
|
+
|
|
272
|
+
Bearer tokens. In dev, set env vars and you're done:
|
|
273
|
+
|
|
274
|
+
```bash
|
|
275
|
+
TOOLCHEST_TOKEN=tcht_dev_secret
|
|
276
|
+
TOOLCHEST_TOKEN_OWNER=user:1
|
|
277
|
+
TOOLCHEST_TOKEN_SCOPES="orders:read orders:write" # optional
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
For production, run the migration and manage with rake:
|
|
281
|
+
|
|
282
|
+
```bash
|
|
283
|
+
rails g toolchest:auth token
|
|
284
|
+
rails db:migrate
|
|
285
|
+
rails toolchest:token:generate OWNER=user:1 NAME="claude desktop"
|
|
286
|
+
rails toolchest:token:list
|
|
287
|
+
rails toolchest:token:revoke TOKEN=tcht_...
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
Toolchest.configure do |config|
|
|
292
|
+
config.auth = :token
|
|
293
|
+
|
|
294
|
+
config.authenticate do |token|
|
|
295
|
+
User.find(token.owner_id)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`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.
|
|
301
|
+
|
|
302
|
+
### :oauth
|
|
303
|
+
|
|
304
|
+
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.
|
|
305
|
+
|
|
306
|
+
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.
|
|
307
|
+
|
|
308
|
+
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.
|
|
309
|
+
|
|
310
|
+
```bash
|
|
311
|
+
rails g toolchest:install --auth=oauth
|
|
312
|
+
rails db:migrate
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
```ruby
|
|
316
|
+
Toolchest.configure do |config|
|
|
317
|
+
config.auth = :oauth
|
|
318
|
+
config.login_path = "/login"
|
|
319
|
+
|
|
320
|
+
config.current_user_for_oauth do |request|
|
|
321
|
+
# return the logged-in user for the consent screen, or nil to redirect
|
|
322
|
+
request.env["warden"]&.user # devise example
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
config.authenticate do |token|
|
|
326
|
+
User.find(token.resource_owner_id)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`authenticate` resolves the token to `auth.resource_owner`. Scopes come from the token and are never lost, even if you return a plain User.
|
|
332
|
+
|
|
333
|
+
You also need `toolchest_oauth` in your routes for `.well-known` discovery:
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# config/routes.rb
|
|
337
|
+
mount Toolchest.app => "/mcp"
|
|
338
|
+
toolchest_oauth
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
This adds the endpoints MCP clients expect:
|
|
342
|
+
|
|
343
|
+
```
|
|
344
|
+
/.well-known/oauth-authorization-server ← discovery (app root)
|
|
345
|
+
/.well-known/oauth-protected-resource ← discovery (app root)
|
|
346
|
+
/mcp/oauth/authorize ← consent screen
|
|
347
|
+
/mcp/oauth/token ← token exchange
|
|
348
|
+
/mcp/oauth/register ← dynamic client registration
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Customize the consent view: `rails g toolchest:consent`
|
|
352
|
+
|
|
353
|
+
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.
|
|
354
|
+
|
|
355
|
+
You can also query tokens directly:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
Toolchest::OauthAccessToken.revoke_all_for(app, user.id)
|
|
359
|
+
Toolchest::OauthAccessGrant.revoke_all_for(app, user.id)
|
|
360
|
+
app.destroy # cascades to all grants and tokens
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Custom
|
|
364
|
+
|
|
365
|
+
If the built-in strategies don't fit, pass any object that responds to `#authenticate(request)`:
|
|
366
|
+
|
|
367
|
+
```ruby
|
|
368
|
+
Toolchest.configure do |config|
|
|
369
|
+
config.auth = WardenAuth.new
|
|
370
|
+
end
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
class WardenAuth
|
|
375
|
+
def authenticate(request)
|
|
376
|
+
user = request.env["warden"]&.user
|
|
377
|
+
return nil unless user
|
|
378
|
+
Toolchest::AuthContext.new(resource_owner: user, scopes: [], token: nil)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
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`.
|
|
384
|
+
|
|
385
|
+
You can inherit from `Toolchest::Auth::Base` to get `extract_bearer_token` for free:
|
|
386
|
+
|
|
387
|
+
```ruby
|
|
388
|
+
class ApiKeyAuth < Toolchest::Auth::Base
|
|
389
|
+
def authenticate(request)
|
|
390
|
+
key = request.env["HTTP_X_API_KEY"]
|
|
391
|
+
ApiKey.active.find_by(key: key)&.owner
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Scopes
|
|
397
|
+
|
|
398
|
+
Scopes work with both `:token` and `:oauth` auth. Define them in your config:
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
config.scopes = {
|
|
402
|
+
"orders:read" => "View order details",
|
|
403
|
+
"orders:write" => "Create and modify orders",
|
|
404
|
+
"users:read" => "View user profiles"
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
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.
|
|
409
|
+
|
|
410
|
+
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.
|
|
411
|
+
|
|
412
|
+
Override when the convention is wrong:
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
tool "Export data", access: :read do
|
|
416
|
+
end
|
|
417
|
+
def export
|
|
418
|
+
# ...
|
|
419
|
+
end
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Turn it off: `config.filter_tools_by_scope = false`
|
|
423
|
+
|
|
424
|
+
### Optional scopes (checkboxes)
|
|
425
|
+
|
|
426
|
+
By default, the consent screen is all-or-nothing — approve all requested scopes or deny. Enable `optional_scopes` and users get checkboxes:
|
|
427
|
+
|
|
428
|
+
```ruby
|
|
429
|
+
config.optional_scopes = true
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
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.
|
|
433
|
+
|
|
434
|
+
Layer on more control when you need it:
|
|
435
|
+
|
|
436
|
+
```ruby
|
|
437
|
+
# These scopes are always granted (checked + disabled on the consent screen)
|
|
438
|
+
config.required_scopes = ["orders:read"]
|
|
439
|
+
|
|
440
|
+
# Per-user gating — hide scopes from users who shouldn't grant them
|
|
441
|
+
config.allowed_scopes_for do |user, requested_scopes|
|
|
442
|
+
user.admin? ? requested_scopes : requested_scopes - ["admin:write"]
|
|
443
|
+
end
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Scopes hidden by `allowed_scopes_for` never appear on the consent screen and can't be granted even if the POST is tampered with.
|
|
447
|
+
|
|
448
|
+
## Multi-mount
|
|
449
|
+
|
|
450
|
+
Separate MCP endpoints, different auth, different toolboxes:
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
Toolchest.configure do |config|
|
|
454
|
+
config.auth = :oauth
|
|
455
|
+
config.toolbox_module = "Public"
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
Toolchest.configure(:admin) do |config|
|
|
459
|
+
config.auth = :token
|
|
460
|
+
config.toolbox_module = "Admin"
|
|
461
|
+
end
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
```ruby
|
|
465
|
+
# config/routes.rb
|
|
466
|
+
mount Toolchest.app => "/mcp"
|
|
467
|
+
mount Toolchest.app(:admin) => "/admin-mcp"
|
|
468
|
+
toolchest_oauth
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Namespace your toolboxes under modules (`Admin::OrdersToolbox`, `Public::OrdersToolbox`) and they route to the right mount.
|
|
472
|
+
|
|
473
|
+
With multiple OAuth mounts, `.well-known` discovery uses the path suffix per [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414) — 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:
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
toolchest_oauth default_mount: :default
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
With a single OAuth mount this isn't needed.
|
|
480
|
+
|
|
481
|
+
## Tool naming
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
config.tool_naming = :underscored # orders_show (default)
|
|
485
|
+
config.tool_naming = :dotted # orders.show
|
|
486
|
+
config.tool_naming = :slashed # orders/show
|
|
487
|
+
config.tool_naming = ->(prefix, method) { "#{prefix}__#{method}" }
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
Per-tool: `tool "description", name: "custom_name"`
|
|
491
|
+
|
|
492
|
+
## Generators
|
|
493
|
+
|
|
494
|
+
```bash
|
|
495
|
+
rails g toolchest:install # initializer, app toolbox, routes
|
|
496
|
+
rails g toolchest Orders show create # toolbox + views + spec
|
|
497
|
+
rails g toolchest Admin::Orders show # namespaced
|
|
498
|
+
rails g toolchest:auth oauth # add auth migration + views
|
|
499
|
+
rails g toolchest:consent # eject consent view
|
|
500
|
+
rails g toolchest:oauth_views # eject all OAuth views + controllers
|
|
501
|
+
rails g toolchest:skills # install Claude Code slash commands
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
## Introspection
|
|
505
|
+
|
|
506
|
+
```bash
|
|
507
|
+
rails toolchest:tools
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
## Testing
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
RSpec.describe OrdersToolbox, type: :toolbox do
|
|
514
|
+
it "shows an order" do
|
|
515
|
+
call_tool "orders_show", params: { order_id: "123" }, as: user
|
|
516
|
+
expect(tool_response).to be_success
|
|
517
|
+
expect(tool_response.text).to include("shipped")
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
it "returns errors for invalid updates" do
|
|
521
|
+
call_tool "orders_update", params: { order_id: "123", status: "pending" }, as: user
|
|
522
|
+
expect(tool_response).to be_error
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
it "suggests next tool after create" do
|
|
526
|
+
call_tool "orders_create", params: { customer_id: "c1" }, as: user
|
|
527
|
+
expect(tool_response).to suggest("orders_show")
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
`require "toolchest/rspec"` in your `rails_helper.rb`.
|
|
533
|
+
|
|
534
|
+
## Security notes
|
|
535
|
+
|
|
536
|
+
- **Rate limiting**: Toolchest doesn't include rate limiting. Use [rack-attack](https://github.com/rack/rack-attack) or your reverse proxy to protect token and registration endpoints.
|
|
537
|
+
- **HTTPS**: OAuth endpoints should always run behind TLS in production.
|
|
538
|
+
|
|
539
|
+
## Internals
|
|
540
|
+
|
|
541
|
+
Transport is the [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) (`mcp` gem).
|
|
542
|
+
|
|
543
|
+
OAuth provider is cribbed from [Doorkeeper](https://github.com/doorkeeper-gem/doorkeeper). Same table layout, same controller shapes. Not a dependency, just stole the design.
|
|
544
|
+
|
|
545
|
+
## For agents
|
|
546
|
+
|
|
547
|
+
If you're implementing this with an agent (or you're the agent reading this), consider the contents of [LLMS.txt](LLMS.txt).
|
|
548
|
+
|
|
549
|
+
## Claude Code skills
|
|
550
|
+
|
|
551
|
+
Install skills for Claude Code that know how to work with toolchest:
|
|
552
|
+
|
|
553
|
+
```bash
|
|
554
|
+
rails g toolchest:skills
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
This gives you `/add-toolbox`, `/add-tool`, and `/toolchest-auth` slash commands.
|
|
558
|
+
|
|
559
|
+
## Requirements
|
|
560
|
+
|
|
561
|
+
- Ruby >= 3.2
|
|
562
|
+
- Rails >= 7.0
|
|
563
|
+
- [jb](https://github.com/amatsuda/jb) (recommended) or jbuilder
|
|
564
|
+
|
|
565
|
+
## Disclaimer
|
|
566
|
+
|
|
567
|
+
This is slop by LLMs for LLMs and only a fool would use it in production. However, I am a fool.
|
|
568
|
+
No implied warranty of any kind, if you trust this and it explodes you please cry to someone else.
|
|
569
|
+
|
|
570
|
+
## License
|
|
571
|
+
|
|
572
|
+
MIT
|