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
@@ -0,0 +1,39 @@
1
+ class CreateToolchestOauth < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :toolchest_oauth_applications do |t|
4
+ t.string :name, null: false
5
+ t.string :uid, null: false, index: { unique: true }
6
+ t.string :secret
7
+ t.text :redirect_uri, null: false
8
+ t.string :scopes, null: false, default: ""
9
+ t.boolean :confidential, null: false, default: true
10
+ t.timestamps
11
+ end
12
+
13
+ create_table :toolchest_oauth_access_grants do |t|
14
+ t.string :resource_owner_id, null: false
15
+ t.references :application, null: false, foreign_key: { to_table: :toolchest_oauth_applications }
16
+ t.string :token_digest, null: false, index: { unique: true }
17
+ t.text :redirect_uri, null: false
18
+ t.string :scopes, null: false, default: ""
19
+ t.string :code_challenge
20
+ t.string :code_challenge_method
21
+ t.string :mount_key, null: false, default: "default"
22
+ t.datetime :expires_at, null: false
23
+ t.datetime :revoked_at
24
+ t.timestamps
25
+ end
26
+
27
+ create_table :toolchest_oauth_access_tokens do |t|
28
+ t.string :resource_owner_id
29
+ t.references :application, null: false, foreign_key: { to_table: :toolchest_oauth_applications }
30
+ t.string :token, null: false, index: { unique: true }
31
+ t.string :refresh_token, index: { unique: true }
32
+ t.string :scopes
33
+ t.string :mount_key, null: false, default: "default"
34
+ t.datetime :expires_at
35
+ t.datetime :revoked_at
36
+ t.timestamps
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ class CreateToolchestTokens < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :toolchest_tokens do |t|
4
+ t.string :token_digest, null: false, index: { unique: true }
5
+ t.string :name
6
+ t.string :owner_type
7
+ t.string :owner_id
8
+ t.string :scopes
9
+ t.string :namespace, default: "default"
10
+ t.datetime :expires_at
11
+ t.datetime :last_used_at
12
+ t.datetime :revoked_at
13
+ t.timestamps
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,41 @@
1
+ Toolchest.configure do |config|
2
+ config.server_name = "<%= Rails.application.class.module_parent_name %>"
3
+ # config.server_description = ""
4
+ config.auth = :<%= auth_strategy %>
5
+ config.mount_path = "/mcp"
6
+ <% if auth_strategy == :token -%>
7
+
8
+ # Resolve the token to a user (or anything). Available as auth.resource_owner in toolboxes.
9
+ config.authenticate do |token|
10
+ # User.find(token.owner_id)
11
+ end
12
+ <% elsif auth_strategy == :oauth -%>
13
+
14
+ config.login_path = "/login"
15
+
16
+ # Identify the logged-in user during the OAuth consent screen.
17
+ # Return a user object, or nil to redirect to login_path.
18
+ config.current_user_for_oauth do |request|
19
+ # Example with Devise:
20
+ # request.env["warden"]&.user
21
+ # Example with a session:
22
+ # User.find_by(id: request.session[:user_id])
23
+ end
24
+
25
+ # Resolve the token to a user (or anything). Available as auth.resource_owner in toolboxes.
26
+ config.authenticate do |token|
27
+ # User.find(token.resource_owner_id)
28
+ end
29
+
30
+ # config.scopes = {
31
+ # "posts:read" => "View blog posts",
32
+ # "posts:write" => "Create and modify blog posts"
33
+ # }
34
+ <% end -%>
35
+
36
+ # Tool naming strategy: :underscored (default), :dotted, :slashed, or a lambda
37
+ # config.tool_naming = :underscored
38
+
39
+ # Filter tools/list by authenticated scopes (default: true)
40
+ # config.filter_tools_by_scope = true
41
+ end
@@ -0,0 +1,48 @@
1
+ <%% # app/views/toolchest/oauth/authorizations/new.html.erb %>
2
+ <%% # Ejected from toolchest. Customize to match your app's design. %>
3
+
4
+ <div class="toolchest-consent">
5
+ <h1><%%= @client_name %> wants access to your account</h1>
6
+ <p>Make sure you trust this URL: <code><%%= @redirect_uri %></code></p>
7
+
8
+ <%%= form_tag @authorize_url, method: :post do %>
9
+ <%% @oauth_params&.each do |key, value| %>
10
+ <%%= hidden_field_tag key, value %>
11
+ <%% end %>
12
+ <%%= hidden_field_tag "original_scope", @original_scope %>
13
+
14
+ <%% if @scope_list&.any? %>
15
+ <p>It's asking for:</p>
16
+ <ul>
17
+ <%% @scope_list.each do |s| %>
18
+ <li>
19
+ <%% if @optional %>
20
+ <label>
21
+ <%%= check_box_tag "scope[]", s[:name], true,
22
+ disabled: s[:required], id: "scope_#{s[:name].parameterize}" %>
23
+ <%% if s[:required] %>
24
+ <%%= hidden_field_tag "scope[]", s[:name] %>
25
+ <%% end %>
26
+ <strong><%%= s[:name] %></strong> &mdash; <%%= s[:description] %>
27
+ </label>
28
+ <%% else %>
29
+ <%%= hidden_field_tag "scope[]", s[:name] %>
30
+ <strong><%%= s[:name] %></strong> &mdash; <%%= s[:description] %>
31
+ <%% end %>
32
+ </li>
33
+ <%% end %>
34
+ </ul>
35
+ <%% end %>
36
+
37
+ <div class="toolchest-consent-actions" style="display: flex; gap: .5rem">
38
+ <%%= submit_tag "Authorize" %>
39
+ </div>
40
+ <%% end %>
41
+
42
+ <%%= form_tag @authorize_url, method: :delete do %>
43
+ <%% @oauth_params&.each do |key, value| %>
44
+ <%%= hidden_field_tag key, value %>
45
+ <%% end %>
46
+ <%%= submit_tag "Deny" %>
47
+ <%% end %>
48
+ </div>
@@ -0,0 +1,19 @@
1
+ class <%= class_name %>Toolbox < <%= parent_class %>
2
+ <% actions.each_with_index do |action, i| -%>
3
+ <%= "\n" if i > 0 -%>
4
+ tool "TODO: describe <%= action %>" do
5
+ # param :id, :string, "TODO: add params"
6
+ end
7
+ def <%= action %>
8
+ # TODO: implement
9
+ end
10
+ <% end -%>
11
+ <% if actions.empty? -%>
12
+ # tool "Description" do
13
+ # param :id, :string, "The ID"
14
+ # end
15
+ # def show
16
+ # @record = Model.find(params[:id])
17
+ # end
18
+ <% end -%>
19
+ end
@@ -0,0 +1,23 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe <%= class_name %>Toolbox, type: :toolbox do
4
+ <% actions.each_with_index do |action, i| -%>
5
+ <%= "\n" if i > 0 -%>
6
+ describe "#<%= action %>" do
7
+ it "works" do
8
+ call_tool "<%= file_path.gsub("/", "_") %>_<%= action %>",
9
+ params: {}
10
+
11
+ expect(tool_response).to be_success
12
+ end
13
+ end
14
+ <% end -%>
15
+ <% if actions.empty? -%>
16
+ # describe "#show" do
17
+ # it "works" do
18
+ # call_tool "<%= file_path.gsub("/", "_") %>_show", params: { id: "1" }
19
+ # expect(tool_response).to be_success
20
+ # end
21
+ # end
22
+ <% end -%>
23
+ end
@@ -0,0 +1,44 @@
1
+ require "rails/generators"
2
+ require "rails/generators/base"
3
+
4
+ module Toolchest
5
+ module Generators
6
+ class ToolboxGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("templates", __dir__)
8
+
9
+ argument :toolbox_name, type: :string
10
+ argument :actions, type: :array, default: []
11
+
12
+ def create_toolbox = template "toolbox.rb.tt", "app/toolboxes/#{file_path}_toolbox.rb"
13
+
14
+ def create_views
15
+ return if actions.empty?
16
+
17
+ actions.each do |action|
18
+ create_file "app/views/toolboxes/#{file_path}/#{action}.json.jb", <<~JB
19
+ {
20
+ # TODO: return data for #{toolbox_name.underscore}##{action}
21
+ }
22
+ JB
23
+ end
24
+ end
25
+
26
+ def create_spec = template "toolbox_spec.rb.tt", "spec/toolboxes/#{file_path}_toolbox_spec.rb"
27
+
28
+ private
29
+
30
+ def file_path = toolbox_name.underscore
31
+
32
+ def class_name = toolbox_name.camelize
33
+
34
+ def parent_class
35
+ if file_path.include?("/")
36
+ namespace = file_path.split("/")[0..-2].map(&:camelize).join("::")
37
+ "#{namespace}::ApplicationToolbox"
38
+ else
39
+ "ApplicationToolbox"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,47 @@
1
+ require "action_dispatch"
2
+
3
+ module Toolchest
4
+ # Rack app for a named mount. Used for multi-mount:
5
+ # mount Toolchest.app(:admin) => "/admin-mcp"
6
+ #
7
+ # Handles OAuth routes (authorize, token, register, authorized_applications)
8
+ # and delegates everything else to the MCP transport (RackApp).
9
+ class App
10
+ attr_reader :mount_key
11
+
12
+ def initialize(mount_key = :default)
13
+ @mount_key = mount_key.to_sym
14
+ @router = build_action_dispatch_router
15
+ end
16
+
17
+ def call(env)
18
+ Engine.ensure_initialized!
19
+ env["toolchest.mount_key"] = @mount_key.to_s
20
+ @router.call(env)
21
+ end
22
+
23
+ private
24
+
25
+ def build_action_dispatch_router
26
+ mk = @mount_key
27
+ endpoint = Endpoint.new
28
+
29
+ ActionDispatch::Routing::RouteSet.new.tap do |routes|
30
+ routes.draw do
31
+ get "oauth/authorize", to: "toolchest/oauth/authorizations#new"
32
+ post "oauth/authorize", to: "toolchest/oauth/authorizations#create"
33
+ delete "oauth/authorize", to: "toolchest/oauth/authorizations#deny"
34
+ post "oauth/token", to: "toolchest/oauth/tokens#create"
35
+ post "oauth/register", to: "toolchest/oauth/registrations#create"
36
+
37
+ resources :oauth_authorized_applications, only: [:index, :destroy],
38
+ path: "oauth/authorized_applications",
39
+ controller: "toolchest/oauth/authorized_applications"
40
+
41
+ match "/", to: endpoint, via: [:get, :post, :delete]
42
+ match "/*path", to: endpoint, via: [:get, :post, :delete]
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,15 @@
1
+ module Toolchest
2
+ module Auth
3
+ class Base
4
+ def authenticate(request) = raise NotImplementedError
5
+
6
+ private
7
+
8
+ def extract_bearer_token(request)
9
+ auth_header = request.env["HTTP_AUTHORIZATION"] || ""
10
+ match = auth_header.match(/\ABearer\s+(.+)\z/i)
11
+ match&.[](1)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module Toolchest
2
+ module Auth
3
+ class None < Base
4
+ def authenticate(request) = nil
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,28 @@
1
+ module Toolchest
2
+ module Auth
3
+ class OAuth < Base
4
+ def initialize(mount_key = :default)
5
+ @mount_key = mount_key.to_s
6
+ end
7
+
8
+ def authenticate(request)
9
+ token_string = extract_bearer_token(request)
10
+ return nil unless token_string
11
+
12
+ token = Toolchest::OauthAccessToken.find_by_token(token_string, mount_key: @mount_key)
13
+ return nil unless token
14
+
15
+ config = Toolchest.configuration(@mount_key.to_sym)
16
+ owner = if config.send(:instance_variable_get, :@authenticate_block)
17
+ config.authenticate_with(token)
18
+ end
19
+
20
+ AuthContext.new(
21
+ resource_owner: owner,
22
+ scopes: token.scopes_array,
23
+ token: token
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,73 @@
1
+ require "openssl"
2
+
3
+ module Toolchest
4
+ module Auth
5
+ class Token < Base
6
+ def authenticate(request)
7
+ token_string = extract_bearer_token(request)
8
+ return nil unless token_string
9
+
10
+ token_record = find_token(token_string)
11
+ return nil unless token_record
12
+
13
+ scopes = if token_record.respond_to?(:scopes_array)
14
+ token_record.scopes_array
15
+ elsif token_record.respond_to?(:scopes)
16
+ Array(token_record.scopes).flat_map { |s| s.split(" ") }.reject(&:empty?)
17
+ else
18
+ []
19
+ end
20
+
21
+ config = Toolchest.configuration
22
+ owner = if config.respond_to?(:authenticate_with) && config.send(:instance_variable_get, :@authenticate_block)
23
+ config.authenticate_with(token_record)
24
+ end
25
+
26
+ AuthContext.new(
27
+ resource_owner: owner,
28
+ scopes: scopes,
29
+ token: token_record
30
+ )
31
+ end
32
+
33
+ private
34
+
35
+ def find_token(token_string)
36
+ env_token = find_env_token(token_string)
37
+ return env_token if env_token
38
+
39
+ find_db_token(token_string)
40
+ end
41
+
42
+ def find_env_token(token_string)
43
+ expected = ENV["TOOLCHEST_TOKEN"]
44
+ return nil unless expected
45
+ return nil unless secure_compare(token_string, expected)
46
+
47
+ owner = ENV["TOOLCHEST_TOKEN_OWNER"]
48
+ EnvTokenRecord.new(token_string, owner)
49
+ end
50
+
51
+ def find_db_token(token_string)
52
+ return nil unless defined?(Toolchest::Token) && Toolchest::Token.table_exists?
53
+
54
+ token = Toolchest::Token.find_by_raw_token(token_string)
55
+ return nil unless token
56
+
57
+ token.update_column(:last_used_at, Time.current)
58
+ token
59
+ end
60
+
61
+ def secure_compare(a, b) = ActiveSupport::SecurityUtils.secure_compare(a, b)
62
+
63
+ EnvTokenRecord = Struct.new(:token, :owner_id) do
64
+ def owner_type
65
+ type, _ = owner_id&.split(":", 2)
66
+ type
67
+ end
68
+
69
+ def scopes = ENV.fetch("TOOLCHEST_TOKEN_SCOPES", "").split(" ").reject(&:empty?)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,13 @@
1
+ module Toolchest
2
+ class AuthContext
3
+ attr_reader :resource_owner, :scopes, :token
4
+
5
+ def initialize(resource_owner:, scopes:, token:)
6
+ @resource_owner = resource_owner
7
+ @scopes = scopes
8
+ @token = token
9
+ end
10
+
11
+ def scopes_array = @scopes
12
+ end
13
+ end
@@ -0,0 +1,82 @@
1
+ module Toolchest
2
+ class Configuration
3
+ VALID_AUTH_STRATEGIES = %i[none token oauth].freeze
4
+
5
+ attr_accessor :tool_naming, :filter_tools_by_scope,
6
+ :server_name, :server_version, :server_description, :server_instructions,
7
+ :scopes, :login_path, :additional_view_paths,
8
+ :access_token_expires_in, :toolboxes, :toolbox_module,
9
+ :mount_key, :mount_path,
10
+ :optional_scopes, :required_scopes
11
+ attr_reader :auth
12
+
13
+ def initialize(mount_key = :default)
14
+ @mount_key = mount_key.to_sym
15
+ @auth = :none
16
+ @tool_naming = :underscored
17
+ @filter_tools_by_scope = true
18
+ @server_name = nil
19
+ @server_version = Toolchest::VERSION
20
+ @scopes = {}
21
+ @login_path = "/login"
22
+ @authenticate_block = nil
23
+ @optional_scopes = false
24
+ @required_scopes = []
25
+ @allowed_scopes_for_block = nil
26
+ @additional_view_paths = []
27
+ @access_token_expires_in = 7200
28
+ @toolboxes = nil
29
+ @toolbox_module = nil
30
+ end
31
+
32
+ def auth=(value)
33
+ if value.respond_to?(:authenticate)
34
+ @auth = value
35
+ elsif value.respond_to?(:to_sym)
36
+ sym = value.to_sym
37
+ unless VALID_AUTH_STRATEGIES.include?(sym)
38
+ raise Toolchest::Error,
39
+ "Invalid auth strategy :#{value}. Valid options: #{VALID_AUTH_STRATEGIES.map { |s| ":#{s}" }.join(', ')}, or an object responding to #authenticate(request)"
40
+ end
41
+ @auth = sym
42
+ else
43
+ raise Toolchest::Error,
44
+ "Auth must be a symbol (:none, :token, :oauth) or an object responding to #authenticate(request)"
45
+ end
46
+ end
47
+
48
+ def authenticate(&block) = @authenticate_block = block
49
+
50
+ def authenticate_with(token)
51
+ return nil unless @authenticate_block
52
+ @authenticate_block.call(token)
53
+ end
54
+
55
+ def allowed_scopes_for(&block)
56
+ if block
57
+ @allowed_scopes_for_block = block
58
+ else
59
+ @allowed_scopes_for_block
60
+ end
61
+ end
62
+
63
+ def resolve_allowed_scopes(user, scopes)
64
+ @allowed_scopes_for_block ? @allowed_scopes_for_block.call(user, scopes) : scopes
65
+ end
66
+
67
+ def current_user_for_oauth(&block)
68
+ if block
69
+ @current_user_for_oauth_block = block
70
+ else
71
+ @current_user_for_oauth_block
72
+ end
73
+ end
74
+
75
+ def resolve_current_user(request)
76
+ return nil unless @current_user_for_oauth_block
77
+ @current_user_for_oauth_block.call(request)
78
+ end
79
+
80
+ def resolved_server_name = @server_name || (defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : "Toolchest")
81
+ end
82
+ end
@@ -0,0 +1,7 @@
1
+ require "active_support/current_attributes"
2
+
3
+ module Toolchest
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :auth, :mount_key, :mcp_session, :mcp_request_id, :mcp_progress_token
6
+ end
7
+ end
@@ -0,0 +1,13 @@
1
+ module Toolchest
2
+ # Rack app for MCP protocol requests (JSON-RPC over HTTP).
3
+ # OAuth endpoints are handled by Rails controllers via engine routes.
4
+ class Endpoint
5
+ def call(env)
6
+ Engine.ensure_initialized!
7
+
8
+ mount_key = (env["toolchest.mount_key"] || "default").to_sym
9
+ app = Toolchest.router(mount_key).rack_app ||= RackApp.new(mount_key: mount_key)
10
+ app.call(env)
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,95 @@
1
+ require "rails/engine"
2
+
3
+ module Toolchest
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Toolchest
6
+
7
+ rake_tasks do
8
+ load File.expand_path("tasks/toolchest.rake", __dir__)
9
+ end
10
+
11
+ initializer "toolchest.autoload_paths", before: :set_autoload_paths do |app|
12
+ app.config.autoload_paths += [Rails.root.join("app", "toolboxes").to_s]
13
+ app.config.eager_load_paths += [Rails.root.join("app", "toolboxes").to_s]
14
+ end
15
+
16
+ initializer "toolchest.setup" do
17
+ config.after_initialize do
18
+ Toolchest::Engine.ensure_initialized!
19
+ end
20
+
21
+ config.to_prepare do
22
+ Toolchest::Engine.reload!
23
+ end
24
+ end
25
+
26
+ class << self
27
+ def ensure_initialized!
28
+ return if @initialized
29
+
30
+ require "mcp"
31
+
32
+ discover_and_assign_toolboxes!
33
+
34
+ @initialized = true
35
+ end
36
+
37
+ def reload!
38
+ @initialized = false
39
+ Toolchest.reset_routers!
40
+ ensure_initialized!
41
+ end
42
+
43
+ private
44
+
45
+ def discover_and_assign_toolboxes!
46
+ return unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
47
+
48
+ toolboxes_path = Rails.root.join("app", "toolboxes")
49
+ return unless toolboxes_path.exist?
50
+
51
+ all_toolboxes = []
52
+
53
+ Dir[toolboxes_path.join("**", "*_toolbox.rb")].each do |file|
54
+ class_name = file
55
+ .sub(toolboxes_path.to_s + "/", "")
56
+ .sub(/\.rb$/, "")
57
+ .camelize
58
+
59
+ next if class_name == "ApplicationToolbox"
60
+
61
+ begin
62
+ klass = class_name.constantize
63
+ all_toolboxes << klass if klass < Toolchest::Toolbox
64
+ rescue NameError
65
+ end
66
+ end
67
+
68
+ # Assign toolboxes to mounts based on config
69
+ Toolchest.mount_keys.each do |mount_key|
70
+ cfg = Toolchest.configuration(mount_key)
71
+ router = Toolchest.router(mount_key)
72
+
73
+ assigned = if cfg.toolboxes
74
+ # Explicit list (supports strings for lazy loading)
75
+ cfg.toolboxes.map { |t| t.is_a?(String) ? t.constantize : t }
76
+ elsif cfg.toolbox_module
77
+ # Module convention
78
+ all_toolboxes.select { |t| t.name&.start_with?("#{cfg.toolbox_module}::") }
79
+ else
80
+ # Default: all toolboxes (only valid for single-mount)
81
+ all_toolboxes
82
+ end
83
+
84
+ assigned.each { |t| router.register(t) }
85
+ end
86
+
87
+ # If no mounts configured yet, register all to :default
88
+ if Toolchest.mount_keys.empty?
89
+ all_toolboxes.each { |t| Toolchest.router(:default).register(t) }
90
+ end
91
+ end
92
+ end
93
+
94
+ end
95
+ end
@@ -0,0 +1,31 @@
1
+ module Toolchest
2
+ module Naming
3
+ class << self
4
+ def generate(toolbox_class, method_name, strategy = :underscored)
5
+ prefix = toolbox_prefix(toolbox_class)
6
+
7
+ case strategy
8
+ when :underscored
9
+ "#{prefix}_#{method_name}"
10
+ when :dotted
11
+ "#{prefix}.#{method_name}"
12
+ when :slashed
13
+ "#{prefix}/#{method_name}"
14
+ when Proc
15
+ strategy.call(prefix, method_name.to_s)
16
+ else
17
+ "#{prefix}_#{method_name}"
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def toolbox_prefix(toolbox_class)
24
+ name = toolbox_class.name || toolbox_class.to_s
25
+ name.underscore
26
+ .chomp("_toolbox")
27
+ .gsub("/", "_")
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ module ActionDispatch
2
+ module Routing
3
+ class Mapper
4
+ # Mount well-known OAuth discovery routes at the app root.
5
+ # MCP clients discover OAuth endpoints via these paths.
6
+ #
7
+ # mount Toolchest::Engine => "/mcp"
8
+ # toolchest_oauth
9
+ #
10
+ # For multi-mount, call once — the (/*rest) suffix lets the
11
+ # MetadataController return the correct endpoints per mount:
12
+ # /.well-known/oauth-authorization-server/mcp → /mcp mount
13
+ # /.well-known/oauth-authorization-server/admin-mcp → /admin-mcp mount
14
+ #
15
+ def toolchest_oauth(default_mount: nil)
16
+ Toolchest.default_oauth_mount = default_mount.to_sym if default_mount
17
+
18
+ get "/.well-known/oauth-authorization-server(/*rest)",
19
+ to: "toolchest/oauth/metadata#authorization_server"
20
+ get "/.well-known/oauth-protected-resource(/*rest)",
21
+ to: "toolchest/oauth/metadata#protected_resource"
22
+ end
23
+ end
24
+ end
25
+ end