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
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<div class="toolchest-consent">
|
|
2
|
+
<h1><%= @client_name %> wants access to your account</h1>
|
|
3
|
+
<p class="toolchest-redirect-uri">Make sure you trust this URL: <code><%= @redirect_uri %></code></p>
|
|
4
|
+
|
|
5
|
+
<%= form_tag @authorize_url, method: :post do %>
|
|
6
|
+
<% @oauth_params&.each do |key, value| %>
|
|
7
|
+
<%= hidden_field_tag key, value %>
|
|
8
|
+
<% end %>
|
|
9
|
+
<%= hidden_field_tag "original_scope", @original_scope %>
|
|
10
|
+
|
|
11
|
+
<% if @scope_list&.any? %>
|
|
12
|
+
<p>It's asking for:</p>
|
|
13
|
+
<ul class="toolchest-scope-list">
|
|
14
|
+
<% @scope_list.each do |s| %>
|
|
15
|
+
<li>
|
|
16
|
+
<% if @optional %>
|
|
17
|
+
<label>
|
|
18
|
+
<%= check_box_tag "scope[]", s[:name], true,
|
|
19
|
+
disabled: s[:required], id: "scope_#{s[:name].parameterize}" %>
|
|
20
|
+
<% if s[:required] %>
|
|
21
|
+
<%= hidden_field_tag "scope[]", s[:name] %>
|
|
22
|
+
<% end %>
|
|
23
|
+
<strong><%= s[:name] %></strong> — <%= s[:description] %>
|
|
24
|
+
</label>
|
|
25
|
+
<% else %>
|
|
26
|
+
<%= hidden_field_tag "scope[]", s[:name] %>
|
|
27
|
+
<strong><%= s[:name] %></strong> — <%= s[:description] %>
|
|
28
|
+
<% end %>
|
|
29
|
+
</li>
|
|
30
|
+
<% end %>
|
|
31
|
+
</ul>
|
|
32
|
+
<% end %>
|
|
33
|
+
|
|
34
|
+
<div class="toolchest-consent-actions" style="display: flex; gap: .5rem">
|
|
35
|
+
<%= submit_tag "Authorize", class: "toolchest-authorize-btn" %>
|
|
36
|
+
</div>
|
|
37
|
+
<% end %>
|
|
38
|
+
|
|
39
|
+
<%= form_tag @authorize_url, method: :delete do %>
|
|
40
|
+
<% @oauth_params&.each do |key, value| %>
|
|
41
|
+
<%= hidden_field_tag key, value %>
|
|
42
|
+
<% end %>
|
|
43
|
+
<%= submit_tag "Deny", class: "toolchest-deny-btn" %>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<div class="toolchest-authorized-apps">
|
|
2
|
+
<h1>Connected MCP Applications</h1>
|
|
3
|
+
<p>These applications have access to your account via MCP.</p>
|
|
4
|
+
|
|
5
|
+
<% if @applications.empty? %>
|
|
6
|
+
<p><em>No applications connected.</em></p>
|
|
7
|
+
<% else %>
|
|
8
|
+
<% @applications.each do |entry| %>
|
|
9
|
+
<div class="toolchest-app-entry">
|
|
10
|
+
<div>
|
|
11
|
+
<strong><%= entry[:application].name %></strong>
|
|
12
|
+
<div class="toolchest-app-scopes">
|
|
13
|
+
<% entry[:scopes].each do |scope| %>
|
|
14
|
+
<code><%= scope %></code>
|
|
15
|
+
<% end %>
|
|
16
|
+
</div>
|
|
17
|
+
<div class="toolchest-app-meta">
|
|
18
|
+
Connected <%= time_ago_in_words(entry[:connected_at]) %> ago
|
|
19
|
+
<% if entry[:last_used_at] %>
|
|
20
|
+
· Last used <%= time_ago_in_words(entry[:last_used_at]) %> ago
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div>
|
|
25
|
+
<%= button_to "Revoke",
|
|
26
|
+
"#{request.script_name}/oauth/authorized_applications/#{entry[:application].id}",
|
|
27
|
+
method: :delete,
|
|
28
|
+
class: "toolchest-revoke-btn",
|
|
29
|
+
data: { turbo_confirm: "Revoke access for #{entry[:application].name}?" } %>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<% end %>
|
|
33
|
+
<% end %>
|
|
34
|
+
</div>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Toolchest::Engine.routes.draw do
|
|
2
|
+
# OAuth endpoints (within engine mount)
|
|
3
|
+
get "oauth/authorize", to: "oauth/authorizations#new"
|
|
4
|
+
post "oauth/authorize", to: "oauth/authorizations#create"
|
|
5
|
+
delete "oauth/authorize", to: "oauth/authorizations#deny"
|
|
6
|
+
post "oauth/token", to: "oauth/tokens#create"
|
|
7
|
+
post "oauth/register", to: "oauth/registrations#create"
|
|
8
|
+
|
|
9
|
+
# User-facing management UI
|
|
10
|
+
resources :oauth_authorized_applications, only: [:index, :destroy],
|
|
11
|
+
path: "oauth/authorized_applications",
|
|
12
|
+
controller: "oauth/authorized_applications"
|
|
13
|
+
|
|
14
|
+
# MCP protocol endpoint (catch-all)
|
|
15
|
+
endpoint = Toolchest::Endpoint.new
|
|
16
|
+
match "/", to: endpoint, via: [:get, :post, :delete]
|
|
17
|
+
match "/*path", to: endpoint, via: [:get, :post, :delete]
|
|
18
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
require "rails/generators/migration"
|
|
4
|
+
|
|
5
|
+
module Toolchest
|
|
6
|
+
module Generators
|
|
7
|
+
class AuthGenerator < Rails::Generators::Base
|
|
8
|
+
include Rails::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def self.next_migration_number(dirname) = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
13
|
+
|
|
14
|
+
argument :strategy, type: :string, desc: "Auth strategy to add (token, oauth)"
|
|
15
|
+
|
|
16
|
+
def validate_strategy
|
|
17
|
+
unless %w[token oauth].include?(strategy)
|
|
18
|
+
raise Thor::Error, "Unknown auth strategy: #{strategy}. Use 'token' or 'oauth'."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create_migration
|
|
23
|
+
case strategy
|
|
24
|
+
when "token"
|
|
25
|
+
migration_template "create_toolchest_tokens.rb.tt",
|
|
26
|
+
"db/migrate/create_toolchest_tokens.rb"
|
|
27
|
+
when "oauth"
|
|
28
|
+
migration_template "create_toolchest_oauth.rb.tt",
|
|
29
|
+
"db/migrate/create_toolchest_oauth.rb"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_consent_view
|
|
34
|
+
return unless strategy == "oauth"
|
|
35
|
+
template "oauth_authorize.html.erb.tt",
|
|
36
|
+
"app/views/toolchest/oauth/authorizations/new.html.erb"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def update_initializer
|
|
40
|
+
say ""
|
|
41
|
+
say "Auth migration created for :#{strategy}.", :green
|
|
42
|
+
say ""
|
|
43
|
+
say "Update your initializer:"
|
|
44
|
+
say " config.auth = :#{strategy}"
|
|
45
|
+
say ""
|
|
46
|
+
say "Then run: rails db:migrate"
|
|
47
|
+
say ""
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def migration_version = "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
|
|
4
|
+
module Toolchest
|
|
5
|
+
module Generators
|
|
6
|
+
class ConsentGenerator < Rails::Generators::Base
|
|
7
|
+
desc "Eject the OAuth consent view for customization"
|
|
8
|
+
|
|
9
|
+
def copy_consent_view
|
|
10
|
+
engine_view = File.expand_path("../../../../app/views/toolchest/oauth/authorizations/new.html.erb", __dir__)
|
|
11
|
+
copy_file engine_view, "app/views/toolchest/oauth/authorizations/new.html.erb"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def show_instructions
|
|
15
|
+
say ""
|
|
16
|
+
say "Consent view ejected!", :green
|
|
17
|
+
say ""
|
|
18
|
+
say " Customize at: app/views/toolchest/oauth/authorizations/new.html.erb"
|
|
19
|
+
say " It renders inside your app's layout (via yield)."
|
|
20
|
+
say ""
|
|
21
|
+
say " Available instance variables:"
|
|
22
|
+
say " @client_name — OAuth application name"
|
|
23
|
+
say " @scope_list — [{ name: 'posts:read', description: 'View posts' }, ...]"
|
|
24
|
+
say " @oauth_params — hash of hidden fields for the form"
|
|
25
|
+
say " @scope_values — raw scope strings"
|
|
26
|
+
say " @authorize_url — POST target for the form"
|
|
27
|
+
say ""
|
|
28
|
+
say " For full control, also eject the controller:"
|
|
29
|
+
say " rails g toolchest:oauth_views"
|
|
30
|
+
say ""
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
require "rails/generators/migration"
|
|
4
|
+
|
|
5
|
+
module Toolchest
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
include Rails::Generators::Migration
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
def self.next_migration_number(dirname) = Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
13
|
+
|
|
14
|
+
class_option :auth, type: :string, default: "none",
|
|
15
|
+
desc: "Auth strategy (none, token, oauth)"
|
|
16
|
+
|
|
17
|
+
def create_application_toolbox = template "application_toolbox.rb.tt", "app/toolboxes/application_toolbox.rb"
|
|
18
|
+
|
|
19
|
+
def create_initializer = template "initializer.rb.tt", "config/initializers/toolchest.rb"
|
|
20
|
+
|
|
21
|
+
def create_toolboxes_directory = empty_directory "app/views/toolboxes"
|
|
22
|
+
|
|
23
|
+
def mount_engine
|
|
24
|
+
route 'mount Toolchest::Engine => "/mcp"'
|
|
25
|
+
route "toolchest_oauth" if auth_strategy == :oauth
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_migrations
|
|
29
|
+
case auth_strategy
|
|
30
|
+
when :token
|
|
31
|
+
migration_template "create_toolchest_tokens.rb.tt",
|
|
32
|
+
"db/migrate/create_toolchest_tokens.rb"
|
|
33
|
+
when :oauth
|
|
34
|
+
migration_template "create_toolchest_oauth.rb.tt",
|
|
35
|
+
"db/migrate/create_toolchest_oauth.rb"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Consent view lives in the engine and works out of the box.
|
|
40
|
+
# Run `rails g toolchest:consent` to eject and customize.
|
|
41
|
+
|
|
42
|
+
def show_instructions
|
|
43
|
+
say ""
|
|
44
|
+
say "Toolchest installed!", :green
|
|
45
|
+
say ""
|
|
46
|
+
say " Auth strategy: #{auth_strategy}"
|
|
47
|
+
say " Mount point: /mcp"
|
|
48
|
+
say ""
|
|
49
|
+
say "Next steps:"
|
|
50
|
+
if auth_strategy != :none
|
|
51
|
+
say " 1. rails db:migrate"
|
|
52
|
+
say " 2. rails g toolchest YourModel show create update"
|
|
53
|
+
say " 3. rails s → point your MCP client at http://localhost:3000/mcp"
|
|
54
|
+
else
|
|
55
|
+
say " 1. rails g toolchest YourModel show create update"
|
|
56
|
+
say " 2. rails s → point your MCP client at http://localhost:3000/mcp"
|
|
57
|
+
end
|
|
58
|
+
say ""
|
|
59
|
+
say "To change auth later: rails g toolchest:auth token (or oauth)"
|
|
60
|
+
say ""
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def auth_strategy = options[:auth].to_sym
|
|
66
|
+
|
|
67
|
+
def migration_version = "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
|
|
4
|
+
module Toolchest
|
|
5
|
+
module Generators
|
|
6
|
+
class OauthViewsGenerator < Rails::Generators::Base
|
|
7
|
+
desc "Eject all OAuth views and controllers for customization"
|
|
8
|
+
|
|
9
|
+
def copy_views
|
|
10
|
+
engine_views = File.expand_path("../../../../app/views/toolchest/oauth", __dir__)
|
|
11
|
+
|
|
12
|
+
Dir[File.join(engine_views, "**", "*.erb")].each do |src|
|
|
13
|
+
relative = src.sub(engine_views + "/", "")
|
|
14
|
+
copy_file src, "app/views/toolchest/oauth/#{relative}"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def copy_controllers
|
|
19
|
+
engine_controllers = File.expand_path("../../../../app/controllers/toolchest/oauth", __dir__)
|
|
20
|
+
|
|
21
|
+
Dir[File.join(engine_controllers, "**", "*.rb")].each do |src|
|
|
22
|
+
relative = src.sub(engine_controllers + "/", "")
|
|
23
|
+
copy_file src, "app/controllers/toolchest/oauth/#{relative}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def show_instructions
|
|
28
|
+
say ""
|
|
29
|
+
say "OAuth views and controllers ejected!", :green
|
|
30
|
+
say ""
|
|
31
|
+
say " Views: app/views/toolchest/oauth/"
|
|
32
|
+
say " Controllers: app/controllers/toolchest/oauth/"
|
|
33
|
+
say ""
|
|
34
|
+
say "Controllers:"
|
|
35
|
+
say " authorizations_controller.rb — consent screen (GET/POST /mcp/oauth/authorize)"
|
|
36
|
+
say " tokens_controller.rb — token exchange (POST /mcp/oauth/token)"
|
|
37
|
+
say " registrations_controller.rb — DCR (POST /register)"
|
|
38
|
+
say " metadata_controller.rb — .well-known endpoints"
|
|
39
|
+
say " authorized_applications_controller.rb — revocation UI (GET/DELETE /mcp/oauth/authorized_applications)"
|
|
40
|
+
say ""
|
|
41
|
+
say "Views:"
|
|
42
|
+
say " authorizations/new.html.erb — consent page"
|
|
43
|
+
say " authorized_applications/index.html.erb — connected apps list"
|
|
44
|
+
say ""
|
|
45
|
+
say "All controllers inherit from ApplicationController."
|
|
46
|
+
say "Override any file — your app's version takes precedence."
|
|
47
|
+
say ""
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
|
|
4
|
+
module Toolchest
|
|
5
|
+
module Generators
|
|
6
|
+
class SkillsGenerator < Rails::Generators::Base
|
|
7
|
+
def create_skills
|
|
8
|
+
create_file ".claude/skills/toolchest-add-toolbox.md", add_toolbox_skill
|
|
9
|
+
create_file ".claude/skills/toolchest-add-tool.md", add_tool_skill
|
|
10
|
+
create_file ".claude/skills/toolchest-auth.md", auth_skill
|
|
11
|
+
|
|
12
|
+
say ""
|
|
13
|
+
say "Claude Code skills installed!", :green
|
|
14
|
+
say ""
|
|
15
|
+
say " /add-toolbox — generate and fill in a new toolbox"
|
|
16
|
+
say " /add-tool — add a tool to an existing toolbox"
|
|
17
|
+
say " /toolchest-auth — set up or change auth"
|
|
18
|
+
say ""
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def add_toolbox_skill
|
|
24
|
+
<<~'SKILL'
|
|
25
|
+
---
|
|
26
|
+
description: Add a new toolbox to this app. Give it a model name and actions, or describe what you want.
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
The user wants to add a new toolbox. A toolbox is a controller for MCP tools — it lives in `app/toolboxes/`.
|
|
30
|
+
|
|
31
|
+
## Steps
|
|
32
|
+
|
|
33
|
+
1. **Determine the model and actions.** If the user says "Orders" or "add tools for orders", look at the `Order` model (columns, associations, validations, scopes) to decide which actions make sense.
|
|
34
|
+
|
|
35
|
+
2. **Run the generator:** `rails g toolchest:toolbox <Name> <actions...>`
|
|
36
|
+
|
|
37
|
+
3. **Fill in the toolbox** with real tool descriptions, params, and implementations. Don't leave TODOs.
|
|
38
|
+
|
|
39
|
+
4. **Create a partial** for the primary model: `app/views/toolboxes/<name>/_<singular>.json.jb`. This is the canonical representation — every tool that returns this model should render this partial.
|
|
40
|
+
|
|
41
|
+
5. **Fill in views** to use the partial. `show.json.jb` should just render the partial. `index.json.jb` should map a collection through the partial.
|
|
42
|
+
|
|
43
|
+
6. **Fill in the spec** at `spec/toolboxes/<name>_toolbox_spec.rb` with real test cases.
|
|
44
|
+
|
|
45
|
+
## Toolbox patterns
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
class OrdersToolbox < ApplicationToolbox
|
|
49
|
+
# default_param adds a param to every tool (except those listed)
|
|
50
|
+
default_param :order_id, :string, "The order ID", except: [:create, :search]
|
|
51
|
+
before_action :set_order, except: [:create, :search]
|
|
52
|
+
|
|
53
|
+
rescue_from ActiveRecord::RecordNotFound do |e|
|
|
54
|
+
render_error "Couldn't find that #{e.model.downcase}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
tool "Look up an order", access: :read do
|
|
58
|
+
end
|
|
59
|
+
def show; end # implicit render of show.json.jb
|
|
60
|
+
|
|
61
|
+
tool "Search orders", access: :read do
|
|
62
|
+
param :status, :string, "Filter by status", optional: true, enum: %w[pending confirmed shipped]
|
|
63
|
+
param :customer_id, :string, "Filter by customer", optional: true
|
|
64
|
+
end
|
|
65
|
+
def search
|
|
66
|
+
@orders = Order.all
|
|
67
|
+
@orders = @orders.where(status: params[:status]) if params[:status]
|
|
68
|
+
@orders = @orders.where(customer_id: params[:customer_id]) if params[:customer_id]
|
|
69
|
+
render :index
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
tool "Create an order", access: :write do
|
|
73
|
+
param :customer_id, :string, "Customer"
|
|
74
|
+
end
|
|
75
|
+
def create
|
|
76
|
+
@order = Order.create!(params.permit(:customer_id).to_h)
|
|
77
|
+
render :show
|
|
78
|
+
suggests :show, "View the created order"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
tool "Update an order", access: :write do
|
|
82
|
+
param :status, :string, "New status", enum: %w[pending confirmed shipped]
|
|
83
|
+
end
|
|
84
|
+
def update
|
|
85
|
+
if @order.update(params.permit(:status).to_h)
|
|
86
|
+
render :show
|
|
87
|
+
else
|
|
88
|
+
render_errors @order
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
def set_order = @order = Order.find(params[:order_id])
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## View patterns — use partials
|
|
98
|
+
|
|
99
|
+
The partial is the single source of truth for how a record is rendered. Don't build bespoke views that manually list fields — use the partial.
|
|
100
|
+
|
|
101
|
+
Check the Gemfile to see whether the project uses **jb** or **jbuilder**, then use the matching syntax:
|
|
102
|
+
|
|
103
|
+
### jb views (`.json.jb`)
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
# app/views/toolboxes/orders/_order.json.jb
|
|
107
|
+
{
|
|
108
|
+
id: order.id,
|
|
109
|
+
status: order.status,
|
|
110
|
+
customer: order.customer.name,
|
|
111
|
+
total: order.total.to_f,
|
|
112
|
+
created_at: order.created_at.iso8601
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
# app/views/toolboxes/orders/show.json.jb
|
|
118
|
+
render partial: "orders/order", locals: { order: @order }
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
# app/views/toolboxes/orders/index.json.jb
|
|
123
|
+
@orders.map { |order| render partial: "orders/order", locals: { order: order } }
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### jbuilder views (`.json.jbuilder`)
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# app/views/toolboxes/orders/_order.json.jbuilder
|
|
130
|
+
json.id order.id
|
|
131
|
+
json.status order.status
|
|
132
|
+
json.customer order.customer.name
|
|
133
|
+
json.total order.total.to_f
|
|
134
|
+
json.created_at order.created_at.iso8601
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
# app/views/toolboxes/orders/show.json.jbuilder
|
|
139
|
+
json.partial! "orders/order", order: @order
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# app/views/toolboxes/orders/index.json.jbuilder
|
|
144
|
+
json.array! @orders, partial: "orders/order", as: :order
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Tool DSL quick reference
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
tool "Description", access: :read do # or :write
|
|
151
|
+
param :name, :string, "description"
|
|
152
|
+
param :name, :string, "description", optional: true
|
|
153
|
+
param :name, :string, "description", enum: %w[a b c]
|
|
154
|
+
param :name, :integer, "description", default: 1
|
|
155
|
+
param :items, [:object], "array of objects" do
|
|
156
|
+
param :field, :string, "nested"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Types: `:string`, `:integer`, `:number`, `:boolean`, `:object`, `[:object]`, `[:string]`
|
|
162
|
+
|
|
163
|
+
### access and annotations
|
|
164
|
+
|
|
165
|
+
Always set `access:` on tools — it controls both scope filtering and client hints:
|
|
166
|
+
- `access: :read` → `readOnlyHint: true, destructiveHint: false`
|
|
167
|
+
- `access: :write` → `readOnlyHint: false, destructiveHint: true`
|
|
168
|
+
|
|
169
|
+
Override with `annotations:` for edge cases:
|
|
170
|
+
```ruby
|
|
171
|
+
tool "Export data", access: :read, annotations: { openWorldHint: true } do
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### default_param
|
|
176
|
+
|
|
177
|
+
Adds a param to every tool in the toolbox. Use for the primary record ID:
|
|
178
|
+
```ruby
|
|
179
|
+
default_param :order_id, :string, "The order ID", except: [:create, :search]
|
|
180
|
+
```
|
|
181
|
+
`except:` and `only:` control which tools get it.
|
|
182
|
+
|
|
183
|
+
### progress for long-running tools
|
|
184
|
+
|
|
185
|
+
```ruby
|
|
186
|
+
def import
|
|
187
|
+
items.each_with_index do |item, i|
|
|
188
|
+
process(item)
|
|
189
|
+
mcp_progress i + 1, total: items.size, message: "Importing #{item.name}"
|
|
190
|
+
end
|
|
191
|
+
render text: "Done"
|
|
192
|
+
end
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### sampling (ask the client's LLM)
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
def summarize
|
|
199
|
+
summary = mcp_sample("Summarize this order", context: @order.to_json)
|
|
200
|
+
render text: summary
|
|
201
|
+
end
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Block form: `mcp_sample { |s| s.system "..."; s.user "..."; s.max_tokens 500 }`
|
|
205
|
+
|
|
206
|
+
Raises `Toolchest::Error` if client doesn't support sampling — handle with `rescue_from`.
|
|
207
|
+
|
|
208
|
+
## Testing
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
require "toolchest/rspec"
|
|
212
|
+
|
|
213
|
+
RSpec.describe OrdersToolbox, type: :toolbox do
|
|
214
|
+
it "shows an order" do
|
|
215
|
+
order = create(:order)
|
|
216
|
+
call_tool "orders_show", params: { order_id: order.id.to_s }, as: auth_context
|
|
217
|
+
expect(tool_response).to be_success
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Matchers: `be_success`, `be_error`, `include_text("str")`, `suggest("tool_name")`
|
|
223
|
+
SKILL
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def add_tool_skill
|
|
227
|
+
<<~'SKILL'
|
|
228
|
+
---
|
|
229
|
+
description: Add a new tool (action) to an existing toolbox.
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
The user wants to add a tool to an existing toolbox.
|
|
233
|
+
|
|
234
|
+
## Steps
|
|
235
|
+
|
|
236
|
+
1. **Find the toolbox** in `app/toolboxes/`. Read it to understand the existing patterns — params, callbacks, error handling.
|
|
237
|
+
|
|
238
|
+
2. **Add the tool** — a `tool` macro + `def` pair. Follow the conventions already in the file.
|
|
239
|
+
|
|
240
|
+
3. **Add a view** if the tool renders (most do). If a partial already exists for the model, use it.
|
|
241
|
+
|
|
242
|
+
4. **Add a test** in the existing spec file.
|
|
243
|
+
|
|
244
|
+
## Quick reference
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
tool "Description for the LLM", access: :read do
|
|
248
|
+
param :query, :string, "Search query"
|
|
249
|
+
param :limit, :integer, "Max results", optional: true, default: 10
|
|
250
|
+
end
|
|
251
|
+
def search
|
|
252
|
+
@results = SomeModel.where("name LIKE ?", "%#{params[:query]}%").limit(params[:limit])
|
|
253
|
+
render :index
|
|
254
|
+
end
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
Always set `access: :read` or `access: :write` on tools.
|
|
258
|
+
|
|
259
|
+
Instance methods available:
|
|
260
|
+
|
|
261
|
+
```
|
|
262
|
+
auth.resource_owner # the user (from authenticate block)
|
|
263
|
+
auth.scopes # token scopes
|
|
264
|
+
params # declared params only
|
|
265
|
+
render :action # render a view
|
|
266
|
+
render json: { ... } # inline
|
|
267
|
+
render text: "..." # plain text
|
|
268
|
+
render_error "msg" # MCP error
|
|
269
|
+
render_errors @record # from ActiveModel errors
|
|
270
|
+
suggests :action, "hint"
|
|
271
|
+
mcp_log :info, "msg"
|
|
272
|
+
mcp_sample "prompt" # ask client's LLM
|
|
273
|
+
mcp_progress n, total: t, message: "..."
|
|
274
|
+
halt error: "forbidden"
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
Options on `tool`:
|
|
278
|
+
- `access: :read` / `:write` — scope filtering + annotations
|
|
279
|
+
- `name: "custom_name"` — override generated tool name
|
|
280
|
+
- `annotations: { openWorldHint: true }` — override hints
|
|
281
|
+
|
|
282
|
+
For long-running tools, use `mcp_progress`. For tools that need LLM help, use `mcp_sample`.
|
|
283
|
+
SKILL
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def auth_skill
|
|
287
|
+
<<~'SKILL'
|
|
288
|
+
---
|
|
289
|
+
description: Set up or change toolchest auth strategy (none, token, oauth).
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
The user wants to configure or change auth for their toolchest MCP endpoint.
|
|
293
|
+
|
|
294
|
+
## Auth strategies
|
|
295
|
+
|
|
296
|
+
### :none
|
|
297
|
+
No auth. `auth` is nil in toolboxes. Good for local dev.
|
|
298
|
+
|
|
299
|
+
### :token
|
|
300
|
+
Bearer tokens. Simplest real auth.
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
# config/initializers/toolchest.rb
|
|
304
|
+
config.auth = :token
|
|
305
|
+
config.authenticate do |token|
|
|
306
|
+
User.find(token.owner_id) # becomes auth.resource_owner
|
|
307
|
+
end
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Dev setup (no DB): set `TOOLCHEST_TOKEN`, `TOOLCHEST_TOKEN_OWNER`, `TOOLCHEST_TOKEN_SCOPES` env vars.
|
|
311
|
+
|
|
312
|
+
Production: `rails g toolchest:auth token && rails db:migrate`, then `rails toolchest:token:generate OWNER=user:1`.
|
|
313
|
+
|
|
314
|
+
### :oauth
|
|
315
|
+
Full OAuth 2.1 + PKCE + DCR. For Claude Desktop, Cursor, etc.
|
|
316
|
+
|
|
317
|
+
```ruby
|
|
318
|
+
config.auth = :oauth
|
|
319
|
+
config.login_path = "/login"
|
|
320
|
+
config.current_user_for_oauth do |request|
|
|
321
|
+
request.env["warden"]&.user # or session lookup
|
|
322
|
+
end
|
|
323
|
+
config.authenticate do |token|
|
|
324
|
+
User.find(token.resource_owner_id)
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Needs: `rails g toolchest:auth oauth && rails db:migrate`
|
|
329
|
+
|
|
330
|
+
Routes: `mount Toolchest.app => "/mcp"` + `toolchest_oauth`
|
|
331
|
+
|
|
332
|
+
### Scopes
|
|
333
|
+
|
|
334
|
+
```ruby
|
|
335
|
+
config.scopes = {
|
|
336
|
+
"orders:read" => "View orders",
|
|
337
|
+
"orders:write" => "Modify orders"
|
|
338
|
+
}
|
|
339
|
+
# Optional: checkboxes on consent
|
|
340
|
+
config.optional_scopes = true
|
|
341
|
+
config.required_scopes = ["orders:read"]
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
## AuthContext
|
|
345
|
+
|
|
346
|
+
`auth` returns `Toolchest::AuthContext`:
|
|
347
|
+
- `auth.resource_owner` — whatever authenticate block returns
|
|
348
|
+
- `auth.scopes` — from the token, never lost
|
|
349
|
+
- `auth.token` — raw token record
|
|
350
|
+
|
|
351
|
+
The generator creates `def current_user = auth&.resource_owner` in ApplicationToolbox.
|
|
352
|
+
SKILL
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class ApplicationToolbox < Toolchest::Toolbox
|
|
2
|
+
# Shared behavior for all toolboxes.
|
|
3
|
+
# Like ApplicationController, but for MCP tools.
|
|
4
|
+
#
|
|
5
|
+
# auth returns a Toolchest::AuthContext with:
|
|
6
|
+
# auth.resource_owner — whatever your authenticate block returns
|
|
7
|
+
# auth.scopes — token scopes (always preserved)
|
|
8
|
+
# auth.token — the raw token record
|
|
9
|
+
def current_user = auth&.resource_owner
|
|
10
|
+
end
|