toolchest 0.3.3 → 0.3.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 545413528704477fdfc39328f3380e82b1f80c9be8096bc36fe201cddf90ce02
4
- data.tar.gz: 4beea37ac5456c21b9b4ec41711643f5c1b2155669e2370badb2e03ad45c0b49
3
+ metadata.gz: db7d29ec671adac8f24512bf3d190dedb55250dfd846b31c5906feac16771130
4
+ data.tar.gz: a3c7414f1c60e5997942fdacfa70ee3a368e6a0cd2b237eab2b1d187351cb10d
5
5
  SHA512:
6
- metadata.gz: 60ef42ecc96cecf2c0a9b9ea26cf786dc72ced48fafa1840cc56f53141657f9791fc2ec2446dc328f6463aaca9cc37564f9365e8a7c851b30256cdb8fb9e3669
7
- data.tar.gz: a8ea33024b69f8154a78d7a4664afdbddc57e01b87c4daa5f01b64dc5a8d1191b44004d9fedd060a57a013bc87cc14f8c462be683d08d82d78708d16210d98e1
6
+ metadata.gz: 68779c02c513ba5dc8052407a015de6fddda8e44c5aa6add4daeb204eb08fe7b41b997a6a2791e90b62ab1dc558d467d3ad722cbee5baca32fccbe7a04692203
7
+ data.tar.gz: d63e96b09cdf89b09a31e902b593f55b4399ab347c5af3daa033c50c03054c654e0338fe2f9eb31544f19522e65bb1c30f9181c33a040e14a2c26dbf91b5371c
data/README.md CHANGED
@@ -31,6 +31,8 @@ rails s
31
31
  ```ruby
32
32
  # app/toolboxes/application_toolbox.rb
33
33
  class ApplicationToolbox < Toolchest::Toolbox
34
+ helper_method :current_user
35
+
34
36
  def current_user = auth&.resource_owner
35
37
 
36
38
  rescue_from ActiveRecord::RecordNotFound do |e|
@@ -39,7 +41,7 @@ class ApplicationToolbox < Toolchest::Toolbox
39
41
  end
40
42
  ```
41
43
 
42
- This is your ApplicationController. `auth` returns a `Toolchest::AuthContext` with `.resource_owner` (whatever your `authenticate` block returns), `.scopes` (always from the token), and `.token` (the raw record). Define `current_user` as a convenience, add shared error handling, include your gems.
44
+ This is your ApplicationController. `auth` returns a `Toolchest::AuthContext` with `.resource_owner` (whatever your `authenticate` block returns), `.scopes` (always from the token), and `.token` (the raw record). Define `current_user` as a convenience and expose it to views with `helper_method`, add shared error handling, include your gems.
43
45
 
44
46
  ```ruby
45
47
  # app/toolboxes/orders_toolbox.rb
@@ -130,6 +132,41 @@ render_errors @order # MCP error from ActiveModel errors
130
132
 
131
133
  `render :show` after mutations. Most toolboxes need one or two views.
132
134
 
135
+ ### View helpers
136
+
137
+ Toolbox methods aren't available in views by default — views only get instance variables. Use `helper_method` to expose methods, same as a Rails controller:
138
+
139
+ ```ruby
140
+ class ApplicationToolbox < Toolchest::Toolbox
141
+ helper_method :current_user, :admin?
142
+
143
+ def current_user = auth&.resource_owner
144
+ def admin? = current_user&.admin?
145
+ end
146
+ ```
147
+
148
+ Now `current_user` and `admin?` work in any view:
149
+
150
+ ```ruby
151
+ # app/views/toolboxes/orders/show.json.jb
152
+ {
153
+ id: @order.id,
154
+ status: @order.status,
155
+ viewer: current_user.name,
156
+ can_edit: admin?
157
+ }
158
+ ```
159
+
160
+ Include entire modules with `helper`:
161
+
162
+ ```ruby
163
+ class ApplicationToolbox < Toolchest::Toolbox
164
+ helper FormattingHelper
165
+ end
166
+ ```
167
+
168
+ Both `helper_method` and `helper` inherit through the toolbox hierarchy — define them in `ApplicationToolbox` and every toolbox gets them.
169
+
133
170
  ### Resources and prompts
134
171
 
135
172
  Supported, though most toolboxes won't need them.
@@ -360,6 +397,25 @@ Toolchest::OauthAccessGrant.revoke_all_for(app, user.id)
360
397
  app.destroy # cascades to all grants and tokens
361
398
  ```
362
399
 
400
+ ### Controller behavior
401
+
402
+ The consent screen and authorized applications page inherit from your app's `ApplicationController` by default — so they get your auth, layout, nav, etc. Route helpers like `root_path` in your layout work automatically (Toolchest delegates unresolved `_path`/`_url` helpers to `main_app`).
403
+
404
+ If you don't want host app behavior on these pages:
405
+
406
+ ```ruby
407
+ # config/initializers/toolchest.rb
408
+ Toolchest.base_controller = "ActionController::Base"
409
+ ```
410
+
411
+ To disable the route helper delegation:
412
+
413
+ ```ruby
414
+ Toolchest.delegate_route_helpers = false
415
+ ```
416
+
417
+ API-only endpoints (token exchange, metadata, dynamic registration) always use `ActionController::API` regardless of this setting.
418
+
363
419
  ### Custom
364
420
 
365
421
  If the built-in strategies don't fit, pass any object that responds to `#authenticate(request)`:
@@ -0,0 +1,5 @@
1
+ module Toolchest
2
+ class ApplicationController < Toolchest.base_controller.constantize
3
+ helper Toolchest::RouteDelegation if Toolchest.delegate_route_helpers
4
+ end
5
+ end
@@ -1,6 +1,6 @@
1
1
  module Toolchest
2
2
  module Oauth
3
- class AuthorizationsController < ::ApplicationController
3
+ class AuthorizationsController < Toolchest::ApplicationController
4
4
  before_action :authenticate_resource_owner!
5
5
  before_action :validate_client!
6
6
 
@@ -1,6 +1,6 @@
1
1
  module Toolchest
2
2
  module Oauth
3
- class AuthorizedApplicationsController < ::ApplicationController
3
+ class AuthorizedApplicationsController < Toolchest::ApplicationController
4
4
  before_action :authenticate_resource_owner!
5
5
 
6
6
  # GET /mcp/oauth/authorized_applications
@@ -1,6 +1,6 @@
1
1
  module Toolchest
2
2
  module Oauth
3
- class MetadataController < ::ApplicationController
3
+ class MetadataController < ActionController::API
4
4
  # GET /.well-known/oauth-authorization-server(/*rest)
5
5
  def authorization_server
6
6
  mount_path, cfg = resolve_mount
@@ -1,7 +1,6 @@
1
1
  module Toolchest
2
2
  module Oauth
3
- class RegistrationsController < ::ApplicationController
4
- skip_forgery_protection
3
+ class RegistrationsController < ActionController::API
5
4
  wrap_parameters false
6
5
 
7
6
  # POST /register — Dynamic Client Registration (RFC 7591)
@@ -9,7 +8,14 @@ module Toolchest
9
8
  # at authorization time via the resource param.
10
9
  def create
11
10
  name = (params[:client_name] || "MCP Client").truncate(255)
12
- uris = Array(params[:redirect_uris])
11
+ uris = Array(params[:redirect_uris]).map(&:to_s)
12
+
13
+ if uris.any? { |u| u.match?(/[\r\n]/) }
14
+ return render json: {
15
+ error: "invalid_client_metadata",
16
+ error_description: "Redirect URIs must not contain newlines"
17
+ }, status: :bad_request
18
+ end
13
19
 
14
20
  if uris.size > 10
15
21
  return render json: {
@@ -1,7 +1,6 @@
1
1
  module Toolchest
2
2
  module Oauth
3
- class TokensController < ::ApplicationController
4
- skip_forgery_protection
3
+ class TokensController < ActionController::API
5
4
 
6
5
  # POST /mcp/oauth/token
7
6
  def create
@@ -46,7 +45,9 @@ module Toolchest
46
45
  return error_response("invalid_grant", "PKCE verification failed")
47
46
  end
48
47
 
49
- grant.revoke!
48
+ unless grant.revoke_atomically!
49
+ return error_response("invalid_grant", "Authorization code already used")
50
+ end
50
51
 
51
52
  token = Toolchest::OauthAccessToken.create_for(
52
53
  application: app,
@@ -24,6 +24,13 @@ module Toolchest
24
24
 
25
25
  def revoke! = update!(revoked_at: Time.current)
26
26
 
27
+ # Atomic revocation — returns true only if THIS call revoked the grant.
28
+ # Prevents race conditions where two concurrent token exchanges both
29
+ # find the grant active and both mint tokens (RFC 6749 §4.1.2).
30
+ def revoke_atomically!
31
+ self.class.where(id: id, revoked_at: nil).update_all(revoked_at: Time.current) > 0
32
+ end
33
+
27
34
  def uses_pkce? = code_challenge.present?
28
35
 
29
36
  def verify_pkce(code_verifier)
@@ -4,11 +4,12 @@ require "rails/generators/base"
4
4
  module Toolchest
5
5
  module Generators
6
6
  class ConsentGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("../../../..", __dir__)
8
+
7
9
  desc "Eject the OAuth consent view for customization"
8
10
 
9
11
  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
+ copy_file "app/views/toolchest/oauth/authorizations/new.html.erb"
12
13
  end
13
14
 
14
15
  def show_instructions
@@ -4,23 +4,25 @@ require "rails/generators/base"
4
4
  module Toolchest
5
5
  module Generators
6
6
  class OauthViewsGenerator < Rails::Generators::Base
7
+ source_root File.expand_path("../../../..", __dir__)
8
+
7
9
  desc "Eject all OAuth views and controllers for customization"
8
10
 
9
11
  def copy_views
10
- engine_views = File.expand_path("../../../../app/views/toolchest/oauth", __dir__)
12
+ views_dir = File.join(self.class.source_root, "app/views/toolchest/oauth")
11
13
 
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}"
14
+ Dir[File.join(views_dir, "**", "*.erb")].each do |src|
15
+ relative = src.sub(views_dir + "/", "")
16
+ copy_file "app/views/toolchest/oauth/#{relative}"
15
17
  end
16
18
  end
17
19
 
18
20
  def copy_controllers
19
- engine_controllers = File.expand_path("../../../../app/controllers/toolchest/oauth", __dir__)
21
+ controllers_dir = File.join(self.class.source_root, "app/controllers/toolchest/oauth")
20
22
 
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}"
23
+ Dir[File.join(controllers_dir, "**", "*.rb")].each do |src|
24
+ relative = src.sub(controllers_dir + "/", "")
25
+ copy_file "app/controllers/toolchest/oauth/#{relative}"
24
26
  end
25
27
  end
26
28
 
@@ -12,6 +12,7 @@ module Toolchest
12
12
 
13
13
  lookup = ActionView::LookupContext.new(view_paths)
14
14
  view = ActionView::Base.with_empty_template_cache.new(lookup, assigns, nil)
15
+ apply_helpers(view, toolbox)
15
16
 
16
17
  result = view.render(template: template_name, formats: [:json])
17
18
 
@@ -73,6 +74,17 @@ module Toolchest
73
74
  assigns
74
75
  end
75
76
 
77
+ def apply_helpers(view, toolbox)
78
+ toolbox.class.helper_modules.each { |mod| view.extend(mod) }
79
+
80
+ toolbox.class.helper_methods.each do |method_name|
81
+ tb = toolbox
82
+ view.define_singleton_method(method_name) do |*args, **kwargs, &block|
83
+ tb.send(method_name, *args, **kwargs, &block)
84
+ end
85
+ end
86
+ end
87
+
76
88
  def view_paths
77
89
  paths = []
78
90
  if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
@@ -0,0 +1,27 @@
1
+ module Toolchest
2
+ # Delegates unresolved _path/_url helpers to main_app so the host
3
+ # application's layout works inside engine-rendered views without
4
+ # requiring main_app. prefixes everywhere.
5
+ #
6
+ # Included as a view helper by the engine when
7
+ # Toolchest.delegate_route_helpers is true (the default).
8
+ #
9
+ # To disable:
10
+ #
11
+ # # config/initializers/toolchest.rb
12
+ # Toolchest.delegate_route_helpers = false
13
+ #
14
+ module RouteDelegation
15
+ def method_missing(method, *args, **kwargs, &block)
16
+ if method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)
17
+ main_app.public_send(method, *args, **kwargs, &block)
18
+ else
19
+ super
20
+ end
21
+ end
22
+
23
+ def respond_to_missing?(method, include_private = false)
24
+ (method.to_s.end_with?("_path", "_url") && main_app.respond_to?(method)) || super
25
+ end
26
+ end
27
+ end
@@ -217,7 +217,12 @@ module Toolchest
217
217
  READ_ACTIONS = Set.new(%i[show index list search]).freeze
218
218
 
219
219
  def tool_allowed_by_scopes?(tool_definition, scopes)
220
- return true if scopes.empty?
220
+ if scopes.empty?
221
+ # Token has no scopes. Allow only if the server hasn't declared any
222
+ # scopes either (convention-based filtering without declarations).
223
+ # When scopes ARE configured, empty token scopes = no access (fail closed).
224
+ return Toolchest.configuration(@mount_key).scopes.empty?
225
+ end
221
226
 
222
227
  if tool_definition.scope
223
228
  return tool_definition.scope.any? { |s| scopes.include?(s) }
@@ -16,6 +16,8 @@ module Toolchest
16
16
  subclass.instance_variable_set(:@_resources, [])
17
17
  subclass.instance_variable_set(:@_prompts, [])
18
18
  subclass.instance_variable_set(:@_pending_tool, nil)
19
+ subclass.instance_variable_set(:@_helper_methods, [])
20
+ subclass.instance_variable_set(:@_helper_modules, [])
19
21
  end
20
22
 
21
23
  def tool_definitions
@@ -117,6 +119,39 @@ module Toolchest
117
119
  @_tool_definitions[method_name.to_sym] = definition
118
120
  end
119
121
 
122
+ # Expose toolbox methods as view helpers, like controller helper_method.
123
+ #
124
+ # helper_method :current_user, :admin?
125
+ #
126
+ def helper_method(*methods)
127
+ @_helper_methods.concat(methods.map(&:to_sym))
128
+ end
129
+
130
+ # Include modules as view helpers.
131
+ #
132
+ # helper ApplicationHelper
133
+ # helper FormattingHelper, CurrencyHelper
134
+ #
135
+ def helper(*modules)
136
+ @_helper_modules.concat(modules)
137
+ end
138
+
139
+ def helper_methods
140
+ ancestors
141
+ .select { |a| a.respond_to?(:own_helper_methods, true) }
142
+ .reverse
143
+ .flat_map { |a| a.send(:own_helper_methods) }
144
+ .uniq
145
+ end
146
+
147
+ def helper_modules
148
+ ancestors
149
+ .select { |a| a.respond_to?(:own_helper_modules, true) }
150
+ .reverse
151
+ .flat_map { |a| a.send(:own_helper_modules) }
152
+ .uniq
153
+ end
154
+
120
155
  def controller_name = name&.underscore&.chomp("_toolbox") || "anonymous"
121
156
 
122
157
  protected
@@ -128,6 +163,10 @@ module Toolchest
128
163
  def own_resources = @_resources || []
129
164
 
130
165
  def own_prompts = @_prompts || []
166
+
167
+ def own_helper_methods = @_helper_methods || []
168
+
169
+ def own_helper_modules = @_helper_modules || []
131
170
  end
132
171
 
133
172
  attr_reader :params
@@ -1,3 +1,3 @@
1
1
  module Toolchest
2
- VERSION = "0.3.3"
2
+ VERSION = "0.3.5"
3
3
  end
data/lib/toolchest.rb CHANGED
@@ -4,6 +4,7 @@ require "toolchest/version"
4
4
 
5
5
  module Toolchest
6
6
  autoload :App, "toolchest/app"
7
+ autoload :RouteDelegation, "toolchest/route_delegation"
7
8
  autoload :AuthContext, "toolchest/auth_context"
8
9
  autoload :Configuration, "toolchest/configuration"
9
10
  autoload :Current, "toolchest/current"
@@ -68,11 +69,28 @@ module Toolchest
68
69
  # When multiple OAuth mounts exist, bare /.well-known/* resolves to this mount
69
70
  attr_accessor :default_oauth_mount
70
71
 
72
+ # Parent class for engine HTML controllers. Default: "::ApplicationController".
73
+ # Set to "ActionController::Base" to avoid inheriting host app behavior.
74
+ attr_writer :base_controller
75
+ def base_controller
76
+ @base_controller || "::ApplicationController"
77
+ end
78
+
79
+ # Delegate unresolved _path/_url helpers to main_app so the host
80
+ # layout renders correctly inside engine views. Default: true.
81
+ # Set to false in an initializer to disable.
82
+ attr_writer :delegate_route_helpers
83
+ def delegate_route_helpers
84
+ @delegate_route_helpers != false
85
+ end
86
+
71
87
  def reset!
72
88
  @configs = nil
73
89
  @routers = nil
74
90
  @apps = nil
75
91
  @default_oauth_mount = nil
92
+ @base_controller = nil
93
+ @delegate_route_helpers = nil
76
94
  end
77
95
 
78
96
  # Reset only routers/apps (preserves config set by initializers)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: toolchest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.3.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nora
@@ -46,6 +46,7 @@ files:
46
46
  - LICENSE
47
47
  - LLMS.txt
48
48
  - README.md
49
+ - app/controllers/toolchest/application_controller.rb
49
50
  - app/controllers/toolchest/oauth/authorizations_controller.rb
50
51
  - app/controllers/toolchest/oauth/authorized_applications_controller.rb
51
52
  - app/controllers/toolchest/oauth/metadata_controller.rb
@@ -88,6 +89,7 @@ files:
88
89
  - lib/toolchest/parameters.rb
89
90
  - lib/toolchest/rack_app.rb
90
91
  - lib/toolchest/renderer.rb
92
+ - lib/toolchest/route_delegation.rb
91
93
  - lib/toolchest/router.rb
92
94
  - lib/toolchest/rspec.rb
93
95
  - lib/toolchest/sampling_builder.rb