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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/LLMS.txt +484 -0
  4. data/README.md +572 -0
  5. data/app/controllers/toolchest/oauth/authorizations_controller.rb +152 -0
  6. data/app/controllers/toolchest/oauth/authorized_applications_controller.rb +68 -0
  7. data/app/controllers/toolchest/oauth/metadata_controller.rb +68 -0
  8. data/app/controllers/toolchest/oauth/registrations_controller.rb +53 -0
  9. data/app/controllers/toolchest/oauth/tokens_controller.rb +98 -0
  10. data/app/models/toolchest/oauth_access_grant.rb +66 -0
  11. data/app/models/toolchest/oauth_access_token.rb +71 -0
  12. data/app/models/toolchest/oauth_application.rb +26 -0
  13. data/app/models/toolchest/token.rb +51 -0
  14. data/app/views/toolchest/oauth/authorizations/new.html.erb +45 -0
  15. data/app/views/toolchest/oauth/authorized_applications/index.html.erb +34 -0
  16. data/config/routes.rb +18 -0
  17. data/lib/generators/toolchest/auth_generator.rb +55 -0
  18. data/lib/generators/toolchest/consent_generator.rb +34 -0
  19. data/lib/generators/toolchest/install_generator.rb +70 -0
  20. data/lib/generators/toolchest/oauth_views_generator.rb +51 -0
  21. data/lib/generators/toolchest/skills_generator.rb +356 -0
  22. data/lib/generators/toolchest/templates/application_toolbox.rb.tt +10 -0
  23. data/lib/generators/toolchest/templates/create_toolchest_oauth.rb.tt +39 -0
  24. data/lib/generators/toolchest/templates/create_toolchest_tokens.rb.tt +16 -0
  25. data/lib/generators/toolchest/templates/initializer.rb.tt +41 -0
  26. data/lib/generators/toolchest/templates/oauth_authorize.html.erb.tt +48 -0
  27. data/lib/generators/toolchest/templates/toolbox.rb.tt +19 -0
  28. data/lib/generators/toolchest/templates/toolbox_spec.rb.tt +23 -0
  29. data/lib/generators/toolchest/toolbox_generator.rb +44 -0
  30. data/lib/toolchest/app.rb +47 -0
  31. data/lib/toolchest/auth/base.rb +15 -0
  32. data/lib/toolchest/auth/none.rb +7 -0
  33. data/lib/toolchest/auth/oauth.rb +28 -0
  34. data/lib/toolchest/auth/token.rb +73 -0
  35. data/lib/toolchest/auth_context.rb +13 -0
  36. data/lib/toolchest/configuration.rb +82 -0
  37. data/lib/toolchest/current.rb +7 -0
  38. data/lib/toolchest/endpoint.rb +13 -0
  39. data/lib/toolchest/engine.rb +95 -0
  40. data/lib/toolchest/naming.rb +31 -0
  41. data/lib/toolchest/oauth/routes.rb +25 -0
  42. data/lib/toolchest/param_definition.rb +69 -0
  43. data/lib/toolchest/parameters.rb +71 -0
  44. data/lib/toolchest/rack_app.rb +114 -0
  45. data/lib/toolchest/renderer.rb +88 -0
  46. data/lib/toolchest/router.rb +277 -0
  47. data/lib/toolchest/rspec.rb +61 -0
  48. data/lib/toolchest/sampling_builder.rb +38 -0
  49. data/lib/toolchest/tasks/toolchest.rake +123 -0
  50. data/lib/toolchest/tool_builder.rb +19 -0
  51. data/lib/toolchest/tool_definition.rb +58 -0
  52. data/lib/toolchest/toolbox.rb +312 -0
  53. data/lib/toolchest/version.rb +3 -0
  54. data/lib/toolchest.rb +89 -0
  55. metadata +122 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9c78a7f55b37d501c0e2d5c4e164048ea4d176f2eb7126c232953a1c4c52e1da
4
+ data.tar.gz: 403d4c3b8ee52953630e52f0d4d31f1a31e9d406511fdfdd7c4f47a67f083bf6
5
+ SHA512:
6
+ metadata.gz: c4c8ca2520fc43e69398fffa2f25a08db7ec0566c7f9453f5fb5d442744e4c42be5ac53ffcb037ad5181a4bac8395970f0ea9b1c925fd6cfe6ab044f1e097b72
7
+ data.tar.gz: b0fd64277dd46191d91681b7c3cbaf14b70b03ac80324fe9d1a01e6b2455486e2d6c6c3c2dbd214430be2867d7f5967538f6ad4a2d76ed855664c44d14c0da65
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nora
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/LLMS.txt ADDED
@@ -0,0 +1,484 @@
1
+ # Toolchest
2
+
3
+ MCP (Model Context Protocol) for Rails. Toolboxes are controllers, tools are actions.
4
+
5
+ ## Adding a toolbox (the thing you'll do most)
6
+
7
+ ```bash
8
+ rails g toolchest:toolbox Orders show create update
9
+ ```
10
+
11
+ This creates three files:
12
+
13
+ - `app/toolboxes/orders_toolbox.rb` — the toolbox (like a controller)
14
+ - `app/views/toolboxes/orders/{show,create,update}.json.jb` — views for each tool
15
+ - `spec/toolboxes/orders_toolbox_spec.rb` — test file
16
+
17
+ Fill in the toolbox:
18
+
19
+ ```ruby
20
+ class OrdersToolbox < ApplicationToolbox
21
+ before_action :set_order, only: [:show, :update]
22
+
23
+ tool "Look up an order" do
24
+ param :order_id, :string, "The order ID"
25
+ end
26
+ def show
27
+ # renders app/views/toolboxes/orders/show.json.jb automatically
28
+ end
29
+
30
+ tool "Update order status" do
31
+ param :order_id, :string, "The order ID"
32
+ param :status, :string, "New status", enum: %w[pending confirmed shipped]
33
+ end
34
+ def update
35
+ @order.update(params.permit(:status).to_h)
36
+ render :show
37
+ end
38
+
39
+ tool "Create a new order" do
40
+ param :customer_id, :string, "Customer"
41
+ end
42
+ def create
43
+ @order = Order.create!(params.permit(:customer_id).to_h)
44
+ render :show
45
+ end
46
+
47
+ private
48
+
49
+ def set_order
50
+ @order = Order.find(params[:order_id])
51
+ end
52
+ end
53
+ ```
54
+
55
+ Fill in the view:
56
+
57
+ ```ruby
58
+ # app/views/toolboxes/orders/show.json.jb
59
+ {
60
+ id: @order.id,
61
+ status: @order.status,
62
+ customer: @order.customer.name
63
+ }
64
+ ```
65
+
66
+ Test it:
67
+
68
+ ```ruby
69
+ require "toolchest/rspec"
70
+
71
+ RSpec.describe OrdersToolbox, type: :toolbox do
72
+ it "shows an order" do
73
+ order = create(:order)
74
+ call_tool "orders_show", params: { order_id: order.id.to_s }, as: user
75
+ expect(tool_response).to be_success
76
+ expect(tool_response.text).to include(order.customer.name)
77
+ end
78
+ end
79
+ ```
80
+
81
+ That's it. The tool is live at your MCP endpoint.
82
+
83
+ ## Common patterns
84
+
85
+ ### Toolbox wrapping an AR model
86
+
87
+ Use `default_param` to avoid repeating the ID param on every tool, and `before_action` to load the record:
88
+
89
+ ```ruby
90
+ class OrdersToolbox < ApplicationToolbox
91
+ default_param :order_id, :string, "The order ID", except: [:create, :search]
92
+ before_action :set_order, except: [:create, :search]
93
+
94
+ rescue_from ActiveRecord::RecordNotFound do |e|
95
+ render_error "Couldn't find that #{e.model.downcase}"
96
+ end
97
+
98
+ tool "Look up an order" do
99
+ end
100
+ def show; end # implicit render
101
+
102
+ tool "Update status" do
103
+ param :status, :string, "New status", enum: %w[pending confirmed shipped]
104
+ end
105
+ def update
106
+ if @order.update(params.permit(:status).to_h)
107
+ render :show
108
+ else
109
+ render_errors @order
110
+ end
111
+ end
112
+
113
+ private
114
+ def set_order = @order = Order.find(params[:order_id])
115
+ end
116
+ ```
117
+
118
+ ### Sampling (ask the client's LLM)
119
+
120
+ ```ruby
121
+ tool "Summarize an order" do
122
+ param :order_id, :string, "Order ID"
123
+ end
124
+ def summarize
125
+ @order = Order.find(params[:order_id])
126
+ summary = mcp_sample("Summarize this order", context: @order.to_json)
127
+ render text: summary
128
+ end
129
+ ```
130
+
131
+ Block form: `mcp_sample { |s| s.system "..."; s.user "..."; s.max_tokens 500; s.temperature 0.3 }`
132
+
133
+ Raises `Toolchest::Error` if the client doesn't support sampling.
134
+
135
+ ### Progress reporting
136
+
137
+ ```ruby
138
+ items.each_with_index do |item, i|
139
+ process(item)
140
+ mcp_progress i + 1, total: items.size, message: "Processing #{item.name}"
141
+ end
142
+ ```
143
+
144
+ No-op if no progress token from client.
145
+
146
+ ### Auth checks in before_action
147
+
148
+ ```ruby
149
+ class AdminToolbox < ApplicationToolbox
150
+ before_action :require_admin!
151
+
152
+ private
153
+ def require_admin!
154
+ halt error: "forbidden" unless current_user&.admin?
155
+ end
156
+ end
157
+ ```
158
+
159
+ ### Nested object params
160
+
161
+ ```ruby
162
+ tool "Create order with items" do
163
+ param :customer_id, :string, "Customer"
164
+ param :items, [:object], "Line items" do
165
+ param :product_id, :string, "Product SKU"
166
+ param :quantity, :integer, "How many", default: 1
167
+ end
168
+ end
169
+ ```
170
+
171
+ ### Suggesting the next tool
172
+
173
+ ```ruby
174
+ def create
175
+ @order = Order.create!(...)
176
+ render :show
177
+ suggests :show, "Get the full order details"
178
+ end
179
+ ```
180
+
181
+ ## Setup
182
+
183
+ ```bash
184
+ bundle add toolchest jb
185
+ rails g toolchest:install --auth=none # or --auth=token, --auth=oauth
186
+ ```
187
+
188
+ ```ruby
189
+ # config/routes.rb — for :none or :token
190
+ mount Toolchest::Engine => "/mcp"
191
+
192
+ # for :oauth — use Toolchest.app and add discovery routes
193
+ mount Toolchest.app => "/mcp"
194
+ toolchest_oauth
195
+ ```
196
+
197
+ ## Tool DSL reference
198
+
199
+ ```ruby
200
+ tool "Description for the LLM" do
201
+ param :name, :type, "description"
202
+ param :name, :type, "description", optional: true
203
+ param :name, :type, "description", enum: %w[a b c]
204
+ param :name, :type, "description", default: "value"
205
+ param :items, [:object], "array of objects" do
206
+ param :nested_field, :string, "nested"
207
+ end
208
+ end
209
+ def method_name
210
+ # implementation
211
+ end
212
+ ```
213
+
214
+ Types: `:string`, `:integer`, `:number`, `:boolean`, `:object`, `[:object]`, `[:string]`, etc.
215
+
216
+ Options on `tool`: `name: "custom_tool_name"`, `access: :read` or `access: :write` (for scope filtering + annotations), `annotations: { openWorldHint: true }` (override hints).
217
+
218
+ `access: :read` → `readOnlyHint: true, destructiveHint: false`. `access: :write` → `readOnlyHint: false, destructiveHint: true`.
219
+
220
+ ## Instance methods in toolbox actions
221
+
222
+ ```ruby
223
+ auth # Toolchest::AuthContext (nil for :none auth)
224
+ auth.resource_owner # whatever your authenticate block returns
225
+ auth.scopes # token scopes (always preserved)
226
+ auth.token # the raw token record
227
+ params # Toolchest::Parameters (declared keys only)
228
+ render :action # renders app/views/toolboxes/{toolbox}/{action}.json.jb
229
+ render "path/to/template" # explicit template path
230
+ render json: { ... } # inline JSON
231
+ render text: "string" # plain text
232
+ # no render call = implicit render using current action name
233
+ render_error "message" # MCP error (isError: true)
234
+ render_errors @record # MCP error from ActiveModel .errors
235
+ suggests :action, "hint" # suggest next tool call to the LLM
236
+ suggests "full_tool_name", "hint" # suggest by full tool name
237
+ mcp_log :info, "message" # MCP log notification
238
+ mcp_sample "prompt" # ask client's LLM, returns text
239
+ mcp_sample { |s| s.user "..." } # block form with system, max_tokens, temperature
240
+ mcp_progress 3, total: 10 # report progress (optional message:)
241
+ halt error: "forbidden" # stop execution, render error
242
+ halt # stop execution (must have rendered already)
243
+ ```
244
+
245
+ ## Auth
246
+
247
+ Three built-in strategies. Default is `:none`.
248
+
249
+ ### :none
250
+
251
+ No auth. `auth` is nil. All tools visible.
252
+
253
+ ### :token
254
+
255
+ ```ruby
256
+ Toolchest.configure do |config|
257
+ config.auth = :token
258
+ config.authenticate do |token|
259
+ # token is EnvTokenRecord (owner_id, scopes) or Toolchest::Token AR record
260
+ # return value becomes auth.resource_owner in toolboxes
261
+ User.find(token.owner_id)
262
+ end
263
+ end
264
+ ```
265
+
266
+ Dev setup (env vars, no DB):
267
+
268
+ ```bash
269
+ TOOLCHEST_TOKEN=tcht_dev_secret
270
+ TOOLCHEST_TOKEN_OWNER=user:1
271
+ TOOLCHEST_TOKEN_SCOPES="orders:read orders:write"
272
+ ```
273
+
274
+ Production (DB tokens):
275
+
276
+ ```bash
277
+ rails g toolchest:auth token && rails db:migrate
278
+ rails toolchest:token:generate OWNER=user:1 NAME="claude desktop"
279
+ ```
280
+
281
+ ### :oauth
282
+
283
+ Full OAuth 2.1 with PKCE and DCR. Isolated from host app auth.
284
+
285
+ ```ruby
286
+ Toolchest.configure do |config|
287
+ config.auth = :oauth
288
+ config.login_path = "/login"
289
+
290
+ config.current_user_for_oauth do |request|
291
+ request.env["warden"]&.user # for consent screen
292
+ end
293
+
294
+ config.authenticate do |token|
295
+ # token is Toolchest::OauthAccessToken (resource_owner_id, scopes, mount_key)
296
+ # scopes are always preserved — returning a User here is fine
297
+ User.find(token.resource_owner_id)
298
+ end
299
+ end
300
+ ```
301
+
302
+ ### Custom auth
303
+
304
+ ```ruby
305
+ config.auth = MyAuth.new
306
+
307
+ class MyAuth < Toolchest::Auth::Base
308
+ def authenticate(request)
309
+ key = request.env["HTTP_X_API_KEY"]
310
+ user = ApiKey.active.find_by(key: key)&.owner
311
+ return nil unless user
312
+ Toolchest::AuthContext.new(resource_owner: user, scopes: [], token: nil)
313
+ end
314
+ end
315
+ ```
316
+
317
+ ### AuthContext
318
+
319
+ `auth` always returns a `Toolchest::AuthContext` (or nil for `:none`):
320
+
321
+ ```ruby
322
+ auth.resource_owner # whatever your authenticate block returns (User, etc.)
323
+ auth.scopes # array of scope strings from the token — never lost
324
+ auth.token # the raw token record
325
+ ```
326
+
327
+ The generator creates `def current_user = auth&.resource_owner` in ApplicationToolbox.
328
+
329
+ Scopes are always extracted from the token *before* the authenticate block runs. You cannot accidentally lose scopes by returning a User from authenticate.
330
+
331
+ ## Scopes
332
+
333
+ ```ruby
334
+ config.scopes = {
335
+ "orders:read" => "View order details",
336
+ "orders:write" => "Create and modify orders"
337
+ }
338
+ ```
339
+
340
+ Pattern: `{toolbox}:{access}`. Actions named `show`, `index`, `list`, `search` default to `:read`, everything else to `:write`. Override per-tool: `tool "desc", access: :read`.
341
+
342
+ `write` scope grants both read and write. Bare scope (e.g. `orders`) grants full access.
343
+
344
+ Scopes filter `tools/list` — clients only see tools their token allows. Fails closed: if scopes can't be determined, no tools are shown.
345
+
346
+ Disable: `config.filter_tools_by_scope = false`
347
+
348
+ ### Optional scopes (checkboxes on consent)
349
+
350
+ ```ruby
351
+ config.optional_scopes = true # users can uncheck scopes (default: false)
352
+ config.required_scopes = ["orders:read"] # always granted, can't uncheck (default: [])
353
+ config.allowed_scopes_for do |user, requested| # per-user gating (default: show all)
354
+ user.admin? ? requested : requested - ["admin:write"]
355
+ end
356
+ ```
357
+
358
+ ## Resources and prompts
359
+
360
+ ```ruby
361
+ class OrdersToolbox < ApplicationToolbox
362
+ resource "orders://schema", name: "Order schema", description: "Fields" do
363
+ { fields: Order.column_names }
364
+ end
365
+
366
+ resource "orders://{order_id}", name: "Order", description: "Order by ID" do |order_id:|
367
+ Order.find(order_id).as_json
368
+ end
369
+
370
+ prompt "debug-order", description: "Debug an order",
371
+ arguments: { order_id: { type: :string, required: true } } do |order_id:|
372
+ order = Order.find(order_id)
373
+ [{ role: "user", content: "Debug this order:\n#{order.to_json}" }]
374
+ end
375
+ end
376
+ ```
377
+
378
+ ## Multi-mount
379
+
380
+ ```ruby
381
+ Toolchest.configure { |c| c.auth = :oauth; c.toolbox_module = "Public" }
382
+ Toolchest.configure(:admin) { |c| c.auth = :token; c.toolbox_module = "Admin" }
383
+
384
+ # routes.rb
385
+ mount Toolchest.app => "/mcp"
386
+ mount Toolchest.app(:admin) => "/admin-mcp"
387
+ toolchest_oauth default_mount: :default
388
+ ```
389
+
390
+ ## Tool naming
391
+
392
+ ```ruby
393
+ config.tool_naming = :underscored # orders_show (default)
394
+ config.tool_naming = :dotted # orders.show
395
+ config.tool_naming = :slashed # orders/show
396
+ config.tool_naming = ->(prefix, method) { "#{prefix}__#{method}" }
397
+ ```
398
+
399
+ Per-tool: `tool "desc", name: "custom_name"`
400
+
401
+ Namespaced toolboxes (`Admin::OrdersToolbox`) produce `admin_orders_{action}`.
402
+
403
+ ## Testing
404
+
405
+ ```ruby
406
+ require "toolchest/rspec"
407
+
408
+ RSpec.describe OrdersToolbox, type: :toolbox do
409
+ it "shows an order" do
410
+ call_tool "orders_show", params: { order_id: "123" }, as: user
411
+ expect(tool_response).to be_success
412
+ expect(tool_response.text).to include("shipped")
413
+ end
414
+
415
+ it "handles errors" do
416
+ call_tool "orders_show", params: { order_id: "missing" }
417
+ expect(tool_response).to be_error
418
+ expect(tool_response).to include_text("not found")
419
+ end
420
+
421
+ it "suggests next tool" do
422
+ call_tool "orders_create", params: { customer_id: "c1" }, as: user
423
+ expect(tool_response).to suggest("orders_show")
424
+ end
425
+ end
426
+ ```
427
+
428
+ Matchers: `be_success`, `be_error`, `include_text("str")`, `suggest("tool_name")`
429
+
430
+ ## Generators
431
+
432
+ ```bash
433
+ rails g toolchest:install # initializer, ApplicationToolbox, routes
434
+ rails g toolchest:install --auth=oauth # same + OAuth migration
435
+ rails g toolchest:toolbox Orders show # toolbox + views + spec
436
+ rails g toolchest:auth token # token migration
437
+ rails g toolchest:auth oauth # OAuth migration
438
+ rails g toolchest:consent # eject consent view
439
+ rails g toolchest:oauth_views # eject all OAuth views + controllers
440
+ rails g toolchest:skills # install Claude Code slash commands
441
+ ```
442
+
443
+ ## Rake tasks
444
+
445
+ ```bash
446
+ rails toolchest:tools # list all registered tools
447
+ rails toolchest:token:generate OWNER=user:1 # create a token
448
+ rails toolchest:token:list # list tokens
449
+ rails toolchest:token:revoke TOKEN=tcht_... # revoke a token
450
+ ```
451
+
452
+ ## Error classes
453
+
454
+ - `Toolchest::ParameterMissing` — raised by `params.require(:key)`
455
+ - `Toolchest::MissingTemplate` — raised when implicit render can't find a view
456
+ - `Toolchest::Error` — base error class
457
+
458
+ Handle with `rescue_from` in your toolbox.
459
+
460
+ ## File locations
461
+
462
+ ```
463
+ app/toolboxes/application_toolbox.rb base class
464
+ app/toolboxes/orders_toolbox.rb toolbox
465
+ app/toolboxes/admin/orders_toolbox.rb namespaced toolbox
466
+ app/views/toolboxes/orders/show.json.jb view
467
+ app/views/toolboxes/admin/orders/show.json.jb namespaced view
468
+ spec/toolboxes/orders_toolbox_spec.rb spec
469
+ config/initializers/toolchest.rb config
470
+ ```
471
+
472
+ ## Server config
473
+
474
+ ```ruby
475
+ Toolchest.configure do |config|
476
+ config.server_name = "My App"
477
+ config.server_description = "Order management tools"
478
+ config.server_instructions = "Always look up the customer before modifying orders."
479
+ end
480
+ ```
481
+
482
+ ## Completion
483
+
484
+ Params with `enum:` automatically power `completion/complete`. No extra code needed.