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,38 @@
1
+ module Toolchest
2
+ class SamplingBuilder
3
+ attr_reader :messages, :system_value, :max_tokens_value, :temperature_value,
4
+ :model_preferences_value, :stop_sequences_value
5
+
6
+ def initialize
7
+ @messages = []
8
+ end
9
+
10
+ def system(text)
11
+ @system_value = text
12
+ end
13
+
14
+ def user(text)
15
+ @messages << { role: "user", content: { type: "text", text: text } }
16
+ end
17
+
18
+ def assistant(text)
19
+ @messages << { role: "assistant", content: { type: "text", text: text } }
20
+ end
21
+
22
+ def max_tokens(n)
23
+ @max_tokens_value = n
24
+ end
25
+
26
+ def temperature(t)
27
+ @temperature_value = t
28
+ end
29
+
30
+ def model_preferences(prefs)
31
+ @model_preferences_value = prefs
32
+ end
33
+
34
+ def stop_sequences(seqs)
35
+ @stop_sequences_value = seqs
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,123 @@
1
+ namespace :toolchest do
2
+ desc "List all registered MCP tools"
3
+ task tools: :environment do
4
+ Toolchest::Engine.ensure_initialized!
5
+ router = Toolchest.router
6
+
7
+ router.toolbox_classes.each do |klass|
8
+ tools = klass.tool_definitions.values
9
+ resources = klass.resources
10
+ prompts = klass.prompts
11
+
12
+ parts = []
13
+ parts << "#{tools.length} tool#{"s" unless tools.length == 1}"
14
+ parts << "#{resources.length} resource#{"s" unless resources.length == 1}" if resources.any?
15
+ parts << "#{prompts.length} prompt#{"s" unless prompts.length == 1}" if prompts.any?
16
+
17
+ puts "#{klass.name} (#{parts.join(", ")})"
18
+
19
+ tools.each do |tool|
20
+ puts " #{tool.tool_name.ljust(25)} #{tool.description.inspect}"
21
+
22
+ required = tool.params.select(&:required?)
23
+ optional = tool.params.reject(&:required?)
24
+
25
+ if required.any?
26
+ params_str = required.map { |p|
27
+ s = "#{p.name} (#{p.type})"
28
+ s += " [#{p.enum.join("|")}]" if p.enum
29
+ s
30
+ }.join(", ")
31
+ puts " Params: #{params_str}"
32
+ end
33
+
34
+ if optional.any?
35
+ optional.each do |p|
36
+ s = "#{p.name} (#{p.type}, optional)"
37
+ s += " [#{p.enum.join("|")}]" if p.enum
38
+ puts " #{s}"
39
+ end
40
+ end
41
+ end
42
+
43
+ resources.each do |r|
44
+ puts " Resource: #{r[:uri]} #{r[:name].inspect}"
45
+ end
46
+
47
+ prompts.each do |p|
48
+ puts " Prompt: #{p[:name]} #{p[:description].inspect}"
49
+ end
50
+
51
+ puts
52
+ end
53
+
54
+ if router.toolbox_classes.empty?
55
+ puts "No toolboxes registered."
56
+ puts "Generate one: rails g toolchest YourModel show create"
57
+ end
58
+ end
59
+
60
+ namespace :token do
61
+ desc "Generate a new API token"
62
+ task generate: :environment do
63
+ owner = ENV["OWNER"]
64
+ name = ENV["NAME"] || "cli-generated"
65
+ scopes = ENV["SCOPES"]
66
+
67
+ if defined?(Toolchest::Token) && Toolchest::Token.table_exists?
68
+ record = Toolchest::Token.generate(owner: owner, name: name, scopes: scopes)
69
+ puts "Token created: #{record.raw_token}"
70
+ puts " Owner: #{owner}" if owner
71
+ puts " Name: #{name}"
72
+ puts " Scopes: #{scopes}" if scopes
73
+ else
74
+ token = "tcht_#{SecureRandom.hex(24)}"
75
+ puts "Token: #{token}"
76
+ puts ""
77
+ puts "No toolchest_tokens table found. Use as env var:"
78
+ puts " TOOLCHEST_TOKEN=#{token}"
79
+ puts " TOOLCHEST_TOKEN_OWNER=#{owner}" if owner
80
+ end
81
+ end
82
+
83
+ desc "List all tokens"
84
+ task list: :environment do
85
+ unless defined?(Toolchest::Token) && Toolchest::Token.table_exists?
86
+ if ENV["TOOLCHEST_TOKEN"]
87
+ puts "Env token configured: TOOLCHEST_TOKEN=#{ENV["TOOLCHEST_TOKEN"][0..8]}..."
88
+ puts " Owner: #{ENV["TOOLCHEST_TOKEN_OWNER"]}" if ENV["TOOLCHEST_TOKEN_OWNER"]
89
+ else
90
+ puts "No tokens configured."
91
+ end
92
+ next
93
+ end
94
+
95
+ tokens = Toolchest::Token.where(revoked_at: nil).order(:created_at)
96
+ if tokens.empty?
97
+ puts "No active tokens."
98
+ else
99
+ tokens.each do |t|
100
+ puts "#{t.token_digest[0..8]}... #{t.name || "(unnamed)"} owner=#{t.owner_type}:#{t.owner_id} created=#{t.created_at.to_date}"
101
+ puts " scopes=#{t.scopes}" if t.scopes.present?
102
+ puts " last_used=#{t.last_used_at}" if t.last_used_at
103
+ end
104
+ end
105
+ end
106
+
107
+ desc "Revoke a token"
108
+ task revoke: :environment do
109
+ token = ENV["TOKEN"]
110
+ abort "Usage: rails toolchest:token:revoke TOKEN=tcht_..." unless token
111
+
112
+ unless defined?(Toolchest::Token) && Toolchest::Token.table_exists?
113
+ abort "No toolchest_tokens table. Can't revoke env tokens — just unset TOOLCHEST_TOKEN."
114
+ end
115
+
116
+ record = Toolchest::Token.find_by_raw_token(token)
117
+ abort "Token not found." unless record
118
+
119
+ record.revoke!
120
+ puts "Token revoked: #{record.name || record.token_digest[0..8]}..."
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,19 @@
1
+ module Toolchest
2
+ class ToolBuilder
3
+ attr_reader :params
4
+
5
+ def initialize = @params = []
6
+
7
+ def param(name, type, description = "", **options, &block)
8
+ @params << ParamDefinition.new(
9
+ name: name,
10
+ type: type,
11
+ description: description,
12
+ optional: options.fetch(:optional, false),
13
+ enum: options[:enum],
14
+ default: options.fetch(:default, :__unset__),
15
+ &block
16
+ )
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,58 @@
1
+ module Toolchest
2
+ class ToolDefinition
3
+ attr_reader :method_name, :description, :params, :toolbox_class, :custom_name, :access_level, :annotations
4
+
5
+ def initialize(method_name:, description:, params:, toolbox_class:, custom_name: nil, access_level: nil, annotations: nil)
6
+ @method_name = method_name.to_sym
7
+ @description = description
8
+ @params = params
9
+ @toolbox_class = toolbox_class
10
+ @custom_name = custom_name
11
+ @access_level = access_level
12
+ @annotations = annotations
13
+ end
14
+
15
+ def tool_name(naming_strategy = nil)
16
+ return @custom_name if @custom_name
17
+ naming_strategy ||= Toolchest.configuration.tool_naming
18
+ Naming.generate(toolbox_class, method_name, naming_strategy)
19
+ end
20
+
21
+ def to_mcp_schema(naming_strategy = nil)
22
+ schema = {
23
+ name: tool_name(naming_strategy),
24
+ description: @description,
25
+ inputSchema: input_schema
26
+ }
27
+ hints = resolved_annotations
28
+ schema[:annotations] = hints if hints.any?
29
+ schema
30
+ end
31
+
32
+ def resolved_annotations
33
+ base = case @access_level
34
+ when :read
35
+ { readOnlyHint: true, destructiveHint: false }
36
+ when :write
37
+ { readOnlyHint: false, destructiveHint: true }
38
+ else
39
+ {}
40
+ end
41
+ base.merge(@annotations || {})
42
+ end
43
+
44
+ def input_schema
45
+ properties = {}
46
+ required = []
47
+
48
+ @params.each do |param|
49
+ properties[param.name] = param.to_json_schema
50
+ required << param.name.to_s if param.required?
51
+ end
52
+
53
+ schema = { type: "object", properties: properties }
54
+ schema[:required] = required if required.any?
55
+ schema
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,312 @@
1
+ require "abstract_controller/callbacks"
2
+ require "active_support/rescuable"
3
+ require "active_support/concern"
4
+
5
+ module Toolchest
6
+ class Toolbox
7
+ include ActiveSupport::Callbacks
8
+ include AbstractController::Callbacks
9
+ include ActiveSupport::Rescuable
10
+
11
+ class << self
12
+ def inherited(subclass)
13
+ super
14
+ subclass.instance_variable_set(:@_tool_definitions, {})
15
+ subclass.instance_variable_set(:@_default_params, [])
16
+ subclass.instance_variable_set(:@_resources, [])
17
+ subclass.instance_variable_set(:@_prompts, [])
18
+ subclass.instance_variable_set(:@_pending_tool, nil)
19
+ end
20
+
21
+ def tool_definitions
22
+ ancestors
23
+ .select { |a| a.respond_to?(:own_tool_definitions, true) }
24
+ .reverse
25
+ .each_with_object({}) { |a, h| h.merge!(a.send(:own_tool_definitions)) }
26
+ end
27
+
28
+ def default_params
29
+ ancestors
30
+ .select { |a| a.respond_to?(:own_default_params, true) }
31
+ .reverse
32
+ .flat_map { |a| a.send(:own_default_params) }
33
+ end
34
+
35
+ def resources
36
+ ancestors
37
+ .select { |a| a.respond_to?(:own_resources, true) }
38
+ .reverse
39
+ .flat_map { |a| a.send(:own_resources) }
40
+ end
41
+
42
+ def prompts
43
+ ancestors
44
+ .select { |a| a.respond_to?(:own_prompts, true) }
45
+ .reverse
46
+ .flat_map { |a| a.send(:own_prompts) }
47
+ end
48
+
49
+ def tool(description, name: nil, access: nil, annotations: nil, &block)
50
+ builder = ToolBuilder.new
51
+ builder.instance_eval(&block) if block
52
+ @_pending_tool = { description: description, custom_name: name, access_level: access, annotations: annotations, builder: builder }
53
+ end
54
+
55
+ def default_param(name, type, description = "", **options)
56
+ @_default_params << {
57
+ param: ParamDefinition.new(
58
+ name: name, type: type, description: description,
59
+ optional: options.fetch(:optional, false),
60
+ enum: options[:enum],
61
+ default: options.fetch(:default, :__unset__)
62
+ ),
63
+ except: Array(options[:except]).map(&:to_sym),
64
+ only: options[:only] ? Array(options[:only]).map(&:to_sym) : nil
65
+ }
66
+ end
67
+
68
+ def resource(uri, name: nil, description: nil, &block)
69
+ template = uri.include?("{")
70
+ @_resources << {
71
+ uri: uri,
72
+ name: name || uri,
73
+ description: description,
74
+ block: block,
75
+ template: template,
76
+ toolbox_class: self
77
+ }
78
+ end
79
+
80
+ def prompt(prompt_name, description: nil, arguments: {}, &block)
81
+ @_prompts << {
82
+ name: prompt_name,
83
+ description: description,
84
+ arguments: arguments,
85
+ block: block,
86
+ toolbox_class: self
87
+ }
88
+ end
89
+
90
+ def method_added(method_name)
91
+ super
92
+ return unless @_pending_tool
93
+
94
+ pending = @_pending_tool
95
+ @_pending_tool = nil
96
+
97
+ params = pending[:builder].params.dup
98
+
99
+ default_params.each do |dp|
100
+ next if dp[:except].include?(method_name.to_sym)
101
+ next if dp[:only] && !dp[:only].include?(method_name.to_sym)
102
+ next if params.any? { |p| p.name == dp[:param].name }
103
+ params.unshift(dp[:param])
104
+ end
105
+
106
+ definition = ToolDefinition.new(
107
+ method_name: method_name,
108
+ description: pending[:description],
109
+ params: params,
110
+ toolbox_class: self,
111
+ custom_name: pending[:custom_name],
112
+ access_level: pending[:access_level],
113
+ annotations: pending[:annotations]
114
+ )
115
+
116
+ @_tool_definitions[method_name.to_sym] = definition
117
+ end
118
+
119
+ def controller_name = name&.underscore&.chomp("_toolbox") || "anonymous"
120
+
121
+ protected
122
+
123
+ def own_tool_definitions = @_tool_definitions || {}
124
+
125
+ def own_default_params = @_default_params || []
126
+
127
+ def own_resources = @_resources || []
128
+
129
+ def own_prompts = @_prompts || []
130
+ end
131
+
132
+ attr_reader :params
133
+
134
+ def initialize(params: {}, tool_definition: nil)
135
+ @params = Parameters.new(params, tool_definition: tool_definition)
136
+ @_tool_definition = tool_definition
137
+ @_response = nil
138
+ @_suggests = []
139
+ end
140
+
141
+ def auth = Toolchest::Current.auth
142
+
143
+ def controller_name = self.class.controller_name
144
+
145
+ def action_name = @_action_name.to_s
146
+
147
+ def performed? = @_response.present?
148
+
149
+ def render(action_or_template = nil, json: nil, text: nil)
150
+ result = if json
151
+ json.is_a?(String) ? json : json.to_json
152
+ elsif text
153
+ text
154
+ else
155
+ rendered = Renderer.render(self, action_or_template || action_name)
156
+ rendered.is_a?(String) ? rendered : rendered.to_json
157
+ end
158
+
159
+ @_response = {
160
+ content: [{ type: "text", text: result }],
161
+ isError: false
162
+ }
163
+ end
164
+
165
+ def render_error(message)
166
+ @_response = {
167
+ content: [{ type: "text", text: message }],
168
+ isError: true
169
+ }
170
+ end
171
+
172
+ def render_errors(record)
173
+ messages = record.errors.full_messages.join(", ")
174
+ render_error("Validation failed: #{messages}")
175
+ end
176
+
177
+ def suggests(tool_name, hint = nil)
178
+ tool_name = tool_name.to_s
179
+ if tool_name.exclude?("_") && tool_name.exclude?(".") && tool_name.exclude?("/")
180
+ tool_name = Naming.generate(self.class, tool_name)
181
+ end
182
+ @_suggests << { tool: tool_name, hint: hint }
183
+ end
184
+
185
+ def halt(**response)
186
+ if response[:error]
187
+ render_error(response[:error])
188
+ end
189
+ throw :halt
190
+ end
191
+
192
+ def mcp_log(level, message) = Toolchest.router(Toolchest::Current.mount_key&.to_sym || :default).notify_log(level: level.to_s, message: message)
193
+
194
+ # Report progress during long-running actions.
195
+ # Client shows a progress bar. total and message are optional.
196
+ def mcp_progress(progress, total: nil, message: nil)
197
+ session = Toolchest::Current.mcp_session
198
+ return unless session
199
+
200
+ token = Toolchest::Current.mcp_progress_token
201
+ return unless token
202
+
203
+ session.notify_progress(
204
+ progress_token: token,
205
+ progress: progress,
206
+ total: total,
207
+ message: message,
208
+ related_request_id: Toolchest::Current.mcp_request_id
209
+ )
210
+ end
211
+
212
+ # Ask the client's LLM to do work. Returns the response text.
213
+ #
214
+ # mcp_sample("Summarize this order", context: @order.to_json)
215
+ #
216
+ # mcp_sample do |s|
217
+ # s.system "You are a fraud analyst"
218
+ # s.user "Analyze: #{@order.to_json}"
219
+ # s.max_tokens 500
220
+ # s.temperature 0.3
221
+ # end
222
+ def mcp_sample(prompt = nil, context: nil, max_tokens: 1024, **kwargs, &block)
223
+ session = Toolchest::Current.mcp_session
224
+ raise Toolchest::Error, "Sampling requires an MCP client that supports it" unless session
225
+
226
+ if block
227
+ builder = SamplingBuilder.new
228
+ yield builder
229
+ messages = builder.messages
230
+ options = { max_tokens: builder.max_tokens_value || max_tokens }
231
+ options[:system_prompt] = builder.system_value if builder.system_value
232
+ options[:temperature] = builder.temperature_value if builder.temperature_value
233
+ options[:model_preferences] = builder.model_preferences_value if builder.model_preferences_value
234
+ options[:stop_sequences] = builder.stop_sequences_value if builder.stop_sequences_value
235
+ else
236
+ text = prompt.to_s
237
+ text = "#{text}\n\n#{context}" if context
238
+ messages = [{ role: "user", content: { type: "text", text: text } }]
239
+ options = { max_tokens: max_tokens }
240
+ options[:system_prompt] = kwargs[:system] if kwargs[:system]
241
+ options[:temperature] = kwargs[:temperature] if kwargs[:temperature]
242
+ end
243
+
244
+ begin
245
+ result = session.create_sampling_message(
246
+ messages: messages,
247
+ related_request_id: Toolchest::Current.mcp_request_id,
248
+ **options
249
+ )
250
+ rescue RuntimeError => e
251
+ raise Toolchest::Error, "Sampling failed: #{e.message}"
252
+ end
253
+
254
+ # Extract text from response
255
+ content = result[:content] || result["content"]
256
+ case content
257
+ when Hash then content[:text] || content["text"]
258
+ when Array then content.map { |c| c[:text] || c["text"] }.compact.join("\n")
259
+ when String then content
260
+ else result.to_s
261
+ end
262
+ end
263
+
264
+ def dispatch(action_name)
265
+ @_action_name = action_name
266
+
267
+ catch(:halt) do
268
+ begin
269
+ process_action(action_name)
270
+ rescue => e
271
+ unless rescue_with_handler(e)
272
+ raise
273
+ end
274
+ end
275
+ end
276
+
277
+ implicit_render! unless performed?
278
+ build_mcp_response
279
+ end
280
+
281
+ private
282
+
283
+ def process_action(action_name)
284
+ run_callbacks :process_action do
285
+ send(action_name)
286
+ end
287
+ end
288
+
289
+ def implicit_render!
290
+ render(action_name)
291
+ rescue Toolchest::MissingTemplate
292
+ raise Toolchest::MissingTemplate,
293
+ "Missing template toolboxes/#{controller_name}/#{action_name}.json.jb"
294
+ end
295
+
296
+ def build_mcp_response
297
+ response = @_response || { content: [], isError: false }
298
+
299
+ if @_suggests.any?
300
+ hints = @_suggests.map { |s|
301
+ text = "Suggested next: call #{s[:tool]}"
302
+ text += " — #{s[:hint]}" if s[:hint]
303
+ text
304
+ }.join("\n")
305
+
306
+ response[:content] << { type: "text", text: hints }
307
+ end
308
+
309
+ response
310
+ end
311
+ end
312
+ end
@@ -0,0 +1,3 @@
1
+ module Toolchest
2
+ VERSION = "0.3.2"
3
+ end
data/lib/toolchest.rb ADDED
@@ -0,0 +1,89 @@
1
+ require "active_support"
2
+ require "active_support/core_ext"
3
+ require "toolchest/version"
4
+
5
+ module Toolchest
6
+ autoload :App, "toolchest/app"
7
+ autoload :AuthContext, "toolchest/auth_context"
8
+ autoload :Configuration, "toolchest/configuration"
9
+ autoload :Current, "toolchest/current"
10
+ autoload :Naming, "toolchest/naming"
11
+ autoload :Parameters, "toolchest/parameters"
12
+ autoload :ParamDefinition, "toolchest/param_definition"
13
+ autoload :Endpoint, "toolchest/endpoint"
14
+ autoload :RackApp, "toolchest/rack_app"
15
+ autoload :Renderer, "toolchest/renderer"
16
+ autoload :Router, "toolchest/router"
17
+ autoload :SamplingBuilder, "toolchest/sampling_builder"
18
+ autoload :Toolbox, "toolchest/toolbox"
19
+ autoload :ToolBuilder, "toolchest/tool_builder"
20
+ autoload :ToolDefinition, "toolchest/tool_definition"
21
+
22
+ module Auth
23
+ autoload :Base, "toolchest/auth/base"
24
+ autoload :None, "toolchest/auth/none"
25
+ autoload :Token, "toolchest/auth/token"
26
+ autoload :OAuth, "toolchest/auth/oauth"
27
+ end
28
+
29
+ class Error < StandardError; end
30
+ class MissingTemplate < Error; end
31
+ class ParameterMissing < Error; end
32
+
33
+ class << self
34
+ # Per-mount configuration
35
+ # Toolchest.configure { } → configures :default
36
+ # Toolchest.configure(:admin) { } → configures :admin
37
+ def configure(name = :default, &block)
38
+ @configs ||= {}
39
+ @configs[name.to_sym] ||= Configuration.new(name)
40
+ yield @configs[name.to_sym] if block
41
+ @configs[name.to_sym]
42
+ end
43
+
44
+ # Toolchest.configuration → :default config (backward compat)
45
+ # Toolchest.configuration(:admin) → :admin config
46
+ def configuration(name = :default)
47
+ @configs ||= {}
48
+ @configs[name.to_sym] ||= Configuration.new(name)
49
+ end
50
+
51
+ # Returns a Rack app for a mount.
52
+ # Toolchest.app → :default app
53
+ # Toolchest.app(:admin) → :admin app
54
+ def app(name = :default)
55
+ @apps ||= {}
56
+ @apps[name.to_sym] ||= App.new(name.to_sym)
57
+ end
58
+
59
+ # Per-mount router
60
+ def router(name = :default)
61
+ @routers ||= {}
62
+ @routers[name.to_sym] ||= Router.new(mount_key: name.to_sym)
63
+ end
64
+
65
+ # All configured mount names
66
+ def mount_keys = (@configs || {}).keys
67
+
68
+ # When multiple OAuth mounts exist, bare /.well-known/* resolves to this mount
69
+ attr_accessor :default_oauth_mount
70
+
71
+ def reset!
72
+ @configs = nil
73
+ @routers = nil
74
+ @apps = nil
75
+ @default_oauth_mount = nil
76
+ end
77
+
78
+ # Reset only routers/apps (preserves config set by initializers)
79
+ def reset_routers!
80
+ @routers = nil
81
+ @apps = nil
82
+ end
83
+ end
84
+ end
85
+
86
+ if defined?(Rails::Engine)
87
+ require "toolchest/engine"
88
+ require "toolchest/oauth/routes"
89
+ end