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,69 @@
1
+ module Toolchest
2
+ class ParamDefinition
3
+ attr_reader :name, :type, :description, :optional, :enum, :default, :children
4
+
5
+ def initialize(name:, type:, description: "", optional: false, enum: nil, default: :__unset__, &block)
6
+ @name = name.to_sym
7
+ @type = type
8
+ @description = description
9
+ @optional = optional
10
+ @enum = enum
11
+ @default = default
12
+ @children = []
13
+
14
+ if block
15
+ builder = ToolBuilder.new
16
+ builder.instance_eval(&block)
17
+ @children = builder.params
18
+ end
19
+ end
20
+
21
+ def required? = !@optional
22
+
23
+ def has_default? = @default != :__unset__
24
+
25
+ def to_json_schema
26
+ schema = case @type
27
+ when :string
28
+ { type: "string" }
29
+ when :integer
30
+ { type: "integer" }
31
+ when :number
32
+ { type: "number" }
33
+ when :boolean
34
+ { type: "boolean" }
35
+ when :object
36
+ object_schema
37
+ when Array
38
+ if @type.first == :object
39
+ { type: "array", items: object_schema }
40
+ else
41
+ { type: "array", items: { type: @type.first.to_s } }
42
+ end
43
+ else
44
+ { type: @type.to_s }
45
+ end
46
+
47
+ schema[:description] = @description if @description.present?
48
+ schema[:enum] = @enum if @enum
49
+ schema[:default] = @default if has_default?
50
+ schema
51
+ end
52
+
53
+ private
54
+
55
+ def object_schema
56
+ props = {}
57
+ required = []
58
+
59
+ @children.each do |child|
60
+ props[child.name] = child.to_json_schema
61
+ required << child.name.to_s if child.required?
62
+ end
63
+
64
+ schema = { type: "object", properties: props }
65
+ schema[:required] = required if required.any?
66
+ schema
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,71 @@
1
+ require "active_support/hash_with_indifferent_access"
2
+
3
+ module Toolchest
4
+ class Parameters
5
+ def initialize(raw = {}, tool_definition: nil)
6
+ @raw = raw.is_a?(Hash) ? raw : {}
7
+
8
+ if tool_definition
9
+ allowed_keys = tool_definition.params.map { |p| p.name.to_s }
10
+ filtered = @raw.select { |k, _| allowed_keys.include?(k.to_s) }
11
+ @params = ActiveSupport::HashWithIndifferentAccess.new(filtered)
12
+ else
13
+ @params = ActiveSupport::HashWithIndifferentAccess.new(@raw)
14
+ end
15
+ end
16
+
17
+ def [](key)
18
+ @params[key]
19
+ end
20
+
21
+ def fetch(key, *args, &block) = @params.fetch(key, *args, &block)
22
+
23
+ def key?(key) = @params.key?(key)
24
+ alias_method :has_key?, :key?
25
+ alias_method :include?, :key?
26
+
27
+ def to_h = @params.to_h
28
+ alias_method :to_hash, :to_h
29
+
30
+ def slice(*keys) = @params.slice(*keys.map(&:to_s))
31
+
32
+ def except(*keys) = @params.except(*keys.map(&:to_s))
33
+
34
+ def require(key)
35
+ value = @params[key]
36
+ if value.nil? && !@params.key?(key.to_s)
37
+ raise Toolchest::ParameterMissing, "param is missing or the value is empty: #{key}"
38
+ end
39
+ value
40
+ end
41
+
42
+ def permit(*keys)
43
+ permitted = {}
44
+ keys.each do |key|
45
+ case key
46
+ when Symbol, String
47
+ permitted[key.to_s] = @params[key] if @params.key?(key.to_s)
48
+ when Hash
49
+ key.each do |k, v|
50
+ if @params.key?(k.to_s) && @params[k].is_a?(Array)
51
+ permitted[k.to_s] = @params[k].map do |item|
52
+ item.is_a?(Hash) ? item.slice(*v.map(&:to_s)) : item
53
+ end
54
+ elsif @params.key?(k.to_s) && @params[k].is_a?(Hash)
55
+ permitted[k.to_s] = @params[k].slice(*v.map(&:to_s))
56
+ end
57
+ end
58
+ end
59
+ end
60
+ ActiveSupport::HashWithIndifferentAccess.new(permitted)
61
+ end
62
+
63
+ def empty? = @params.empty?
64
+
65
+ def each(&block) = @params.each(&block)
66
+
67
+ def merge(other) = self.class.new(@params.merge(other))
68
+
69
+ def inspect = "#<Toolchest::Parameters #{@params.inspect}>"
70
+ end
71
+ end
@@ -0,0 +1,114 @@
1
+ module Toolchest
2
+ class RackApp
3
+ attr_reader :mount_key
4
+
5
+ def initialize(mount_key: :default)
6
+ @mount_key = mount_key.to_sym
7
+ @server = build_mcp_server
8
+ @transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server)
9
+ @server.transport = @transport
10
+ install_handlers!
11
+ end
12
+
13
+ def call(env)
14
+ request = Rack::Request.new(env)
15
+ env["toolchest.mount_key"] ||= @mount_key.to_s
16
+
17
+ auth = authenticate(request)
18
+
19
+ if auth.nil? && config.auth != :none
20
+ mount_path = config.mount_path || "/mcp"
21
+ resource_metadata = "#{request.base_url}/.well-known/oauth-protected-resource#{mount_path}"
22
+ return [401, {
23
+ "WWW-Authenticate" => %(Bearer resource_metadata="#{resource_metadata}"),
24
+ "Content-Type" => "application/json"
25
+ }, ['{"error":"unauthorized"}']]
26
+ end
27
+
28
+ Toolchest::Current.set(auth: auth, mount_key: @mount_key.to_s) do
29
+ status, headers, body = @transport.handle_request(request)
30
+ # The transport may return a streaming body (proc) that executes after
31
+ # Current.set unwinds. Wrap it to restore Current for the duration.
32
+ wrapped_body = if body.respond_to?(:call)
33
+ captured_auth = auth
34
+ captured_mount = @mount_key.to_s
35
+ proc { |stream|
36
+ Toolchest::Current.set(auth: captured_auth, mount_key: captured_mount) do
37
+ body.call(stream)
38
+ end
39
+ }
40
+ else
41
+ body
42
+ end
43
+ [status, headers.dup, wrapped_body]
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def config = Toolchest.configuration(@mount_key)
50
+
51
+ def build_mcp_server
52
+ opts = {
53
+ name: config.resolved_server_name,
54
+ version: config.server_version,
55
+ capabilities: {
56
+ tools: { listChanged: true },
57
+ prompts: { listChanged: true },
58
+ resources: { listChanged: true },
59
+ logging: {},
60
+ completions: {}
61
+ }
62
+ }
63
+
64
+ opts[:description] = config.server_description if config.server_description
65
+ opts[:instructions] = config.server_instructions if config.server_instructions
66
+
67
+ MCP::Server.new(**opts)
68
+ end
69
+
70
+ def install_handlers!
71
+ router = Toolchest.router(@mount_key)
72
+ server = @server
73
+
74
+ router.mcp_server = server
75
+
76
+ handlers = server.instance_variable_get(:@handlers)
77
+
78
+ handlers[MCP::Methods::TOOLS_LIST] = ->(params) { router.tools_for_handler }
79
+ handlers[MCP::Methods::RESOURCES_LIST] = ->(params) { router.resources_for_handler }
80
+ handlers[MCP::Methods::RESOURCES_READ] = ->(params) { router.resources_read_response(params) }
81
+ handlers[MCP::Methods::RESOURCES_TEMPLATES_LIST] = ->(params) { router.resource_templates_for_handler }
82
+ handlers[MCP::Methods::PROMPTS_LIST] = ->(params) { router.prompts_for_handler }
83
+ handlers[MCP::Methods::PROMPTS_GET] = ->(params) { router.prompts_get_response(params) }
84
+
85
+ # tools/call is hardcoded in handle_request to call private call_tool
86
+ server.define_singleton_method(:call_tool) do |params, session: nil, related_request_id: nil|
87
+ progress_token = params.dig(:_meta, :progressToken)
88
+ Toolchest::Current.mcp_session = session
89
+ Toolchest::Current.mcp_request_id = related_request_id
90
+ Toolchest::Current.mcp_progress_token = progress_token
91
+ router.dispatch_response(params)
92
+ end
93
+
94
+ # completion/complete is hardcoded to call private complete, which validates
95
+ # against registered prompts/resources (we don't register any). override it.
96
+ server.define_singleton_method(:complete) do |params|
97
+ arg_name = params.dig(:argument, :name) || params.dig(:argument, "name")
98
+ values = arg_name ? router.completion_values(arg_name) : []
99
+ { completion: { values: values, hasMore: false } }
100
+ end
101
+ end
102
+
103
+ def authenticate(request)
104
+ strategy = case config.auth
105
+ when :none then Auth::None.new
106
+ when :token then Auth::Token.new
107
+ when :oauth then Auth::OAuth.new(@mount_key)
108
+ else config.auth
109
+ end
110
+
111
+ strategy.authenticate(request)
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,88 @@
1
+ require "action_view"
2
+
3
+ module Toolchest
4
+ module Renderer
5
+ class << self
6
+ def render(toolbox, action_or_template)
7
+ ensure_handlers_registered!
8
+
9
+ name = action_or_template.to_s
10
+ template_name = name.include?("/") ? name : "#{toolbox.controller_name}/#{name}"
11
+ assigns = extract_assigns(toolbox)
12
+
13
+ lookup = ActionView::LookupContext.new(view_paths)
14
+ view = ActionView::Base.with_empty_template_cache.new(lookup, assigns, nil)
15
+
16
+ result = view.render(template: template_name, formats: [:json])
17
+
18
+ case result
19
+ when String
20
+ # jb returns JSON string via monkey patches, jbuilder returns JSON string natively
21
+ begin
22
+ JSON.parse(result)
23
+ rescue JSON::ParserError
24
+ result
25
+ end
26
+ when Hash, Array
27
+ result
28
+ else
29
+ result
30
+ end
31
+ rescue ActionView::MissingTemplate
32
+ raise Toolchest::MissingTemplate,
33
+ "Missing template toolboxes/#{template_name} with formats: json (searched in: #{view_paths.join(", ")})"
34
+ end
35
+
36
+ private
37
+
38
+ def ensure_handlers_registered!
39
+ return if @handlers_registered
40
+
41
+ handler_found = false
42
+
43
+ # Register jb handler if available
44
+ begin
45
+ require "jb/handler"
46
+ require "jb/action_view_monkeys"
47
+ ActionView::Template.register_template_handler :jb, Jb::Handler
48
+ handler_found = true
49
+ rescue LoadError
50
+ end
51
+
52
+ # jbuilder registers via its own railtie, but if we're outside Rails boot:
53
+ begin
54
+ require "jbuilder/jbuilder_template"
55
+ handler_found = true
56
+ rescue LoadError
57
+ end
58
+
59
+ unless handler_found
60
+ warn "[Toolchest] No template handler found. Add gem 'jb' (recommended) or gem 'jbuilder' to your Gemfile."
61
+ end
62
+
63
+ @handlers_registered = true
64
+ end
65
+
66
+ def extract_assigns(toolbox)
67
+ assigns = {}
68
+ toolbox.instance_variables.each do |ivar|
69
+ next if ivar.to_s.start_with?("@_")
70
+ key = ivar.to_s.sub("@", "")
71
+ assigns[key] = toolbox.instance_variable_get(ivar)
72
+ end
73
+ assigns
74
+ end
75
+
76
+ def view_paths
77
+ paths = []
78
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
79
+ paths << Rails.root.join("app", "views", "toolboxes").to_s
80
+ end
81
+ paths += Toolchest.configuration.additional_view_paths
82
+ paths
83
+ end
84
+
85
+ def reset! = @handlers_registered = false
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,277 @@
1
+ module Toolchest
2
+ class Router
3
+ attr_accessor :mcp_server, :rack_app
4
+
5
+ def initialize(mount_key: :default)
6
+ @mount_key = mount_key.to_sym
7
+ @tool_map = {}
8
+ @toolbox_classes = []
9
+ @mcp_server = nil
10
+ @rack_app = nil
11
+ end
12
+
13
+ def register(toolbox_class)
14
+ @toolbox_classes << toolbox_class unless @toolbox_classes.include?(toolbox_class)
15
+ rebuild_tool_map!
16
+ end
17
+
18
+ def toolbox_classes = @toolbox_classes
19
+
20
+ def tools_list = tool_definitions.map { |td| td.to_mcp_schema }
21
+
22
+ # For the MCP SDK handler — returns array (SDK wraps it)
23
+ def tools_for_handler
24
+ config = Toolchest.configuration(@mount_key)
25
+
26
+ unless config.filter_tools_by_scope
27
+ return tools_list
28
+ end
29
+
30
+ auth = Toolchest::Current.auth
31
+
32
+ # No auth: show all tools for :none, show nothing otherwise
33
+ unless auth
34
+ return config.auth == :none ? tools_list : []
35
+ end
36
+
37
+ scopes = extract_scopes(auth)
38
+
39
+ # Auth present but no scopes extractable: fail closed
40
+ unless scopes
41
+ return []
42
+ end
43
+
44
+ tool_definitions.select { |td| tool_allowed_by_scopes?(td, scopes) }
45
+ .map { |td| td.to_mcp_schema }
46
+ end
47
+
48
+ def dispatch(tool_name, arguments = {})
49
+ definition = find_tool(tool_name)
50
+ unless definition
51
+ return { content: [{ type: "text", text: "Unknown tool: #{tool_name}" }], isError: true }
52
+ end
53
+
54
+ config = Toolchest.configuration(@mount_key)
55
+ if config.filter_tools_by_scope
56
+ auth = Toolchest::Current.auth
57
+ scopes = auth ? extract_scopes(auth) : nil
58
+ if config.auth != :none && (!scopes || !tool_allowed_by_scopes?(definition, scopes))
59
+ return { content: [{ type: "text", text: "Forbidden: insufficient scope" }], isError: true }
60
+ end
61
+ end
62
+
63
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ auth = Toolchest::Current.auth
65
+ token_hint = extract_token_hint(auth)
66
+
67
+ log_request_start(definition, arguments, token_hint)
68
+
69
+ toolbox = definition.toolbox_class.new(
70
+ params: arguments,
71
+ tool_definition: definition
72
+ )
73
+ response = toolbox.dispatch(definition.method_name)
74
+
75
+ duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
76
+ log_request_complete(definition, response, duration)
77
+
78
+ response
79
+ end
80
+
81
+ def dispatch_response(params)
82
+ name = params[:name] || params["name"]
83
+ arguments = params[:arguments] || params["arguments"] || {}
84
+ dispatch(name, arguments)
85
+ end
86
+
87
+ def resources_list = @toolbox_classes.flat_map(&:resources).reject { |r| r[:template] }
88
+
89
+ def resources_for_handler
90
+ resources_list.map { |r|
91
+ { uri: r[:uri], name: r[:name], description: r[:description] }.compact
92
+ }
93
+ end
94
+
95
+ def resource_templates_for_handler
96
+ @toolbox_classes.flat_map(&:resources).select { |r| r[:template] }.map { |r|
97
+ { uriTemplate: r[:uri], name: r[:name], description: r[:description] }.compact
98
+ }
99
+ end
100
+
101
+ def resources_read(uri)
102
+ resource = @toolbox_classes.flat_map(&:resources).find { |r|
103
+ if r[:template]
104
+ pattern = r[:uri].gsub(/\{[^}]+\}/, "([^/]+)")
105
+ uri.match?(Regexp.new("^#{pattern}$"))
106
+ else
107
+ r[:uri] == uri
108
+ end
109
+ }
110
+
111
+ unless resource
112
+ return [{ uri: uri, mimeType: "text/plain", text: "Resource not found: #{uri}" }]
113
+ end
114
+
115
+ result = if resource[:template]
116
+ pattern = resource[:uri].gsub(/\{([^}]+)\}/, '(?<\1>[^/]+)')
117
+ match = uri.match(Regexp.new("^#{pattern}$"))
118
+ kwargs = match.named_captures.transform_keys(&:to_sym)
119
+ resource[:block].call(**kwargs)
120
+ else
121
+ resource[:block].call
122
+ end
123
+
124
+ [{ uri: uri, mimeType: "application/json", text: result.to_json }]
125
+ end
126
+
127
+ def resources_read_response(params)
128
+ uri = params[:uri] || params["uri"]
129
+ resources_read(uri)
130
+ end
131
+
132
+ def prompts_list = @toolbox_classes.flat_map(&:prompts)
133
+
134
+ def prompts_for_handler
135
+ prompts_list.map { |p|
136
+ prompt = { name: p[:name], description: p[:description] }.compact
137
+ if p[:arguments].any?
138
+ prompt[:arguments] = p[:arguments].map { |name, opts|
139
+ arg = { name: name.to_s }
140
+ arg[:description] = opts[:description] if opts[:description]
141
+ arg[:required] = opts[:required] if opts.key?(:required)
142
+ arg
143
+ }
144
+ end
145
+ prompt
146
+ }
147
+ end
148
+
149
+ def prompts_get(name, arguments = {})
150
+ prompt = prompts_list.find { |p| p[:name] == name }
151
+ return { messages: [] } unless prompt
152
+
153
+ kwargs = arguments.transform_keys(&:to_sym)
154
+ messages = prompt[:block].call(**kwargs)
155
+ { messages: messages }
156
+ end
157
+
158
+ def prompts_get_response(params)
159
+ name = params[:name] || params["name"]
160
+ arguments = params[:arguments] || params["arguments"] || {}
161
+ prompts_get(name, arguments)
162
+ end
163
+
164
+ def completion_values(argument_name)
165
+ tool_definitions.flat_map(&:params)
166
+ .select { |p| p.name.to_s == argument_name.to_s && p.enum }
167
+ .flat_map(&:enum)
168
+ .uniq
169
+ end
170
+
171
+ def notify_log(level:, message:)
172
+ return unless @mcp_server
173
+ @mcp_server.notify_log_message(
174
+ data: message,
175
+ level: level,
176
+ logger: "Toolchest"
177
+ )
178
+ end
179
+
180
+ private
181
+
182
+ def tool_definitions = @toolbox_classes.flat_map { |klass| klass.tool_definitions.values }
183
+
184
+ def find_tool(tool_name)
185
+ rebuild_tool_map! if @tool_map.empty? && @toolbox_classes.any?
186
+ @tool_map[tool_name]
187
+ end
188
+
189
+ def rebuild_tool_map!
190
+ @tool_map = {}
191
+ tool_definitions.each do |td|
192
+ if @tool_map.key?(td.tool_name) && @tool_map[td.tool_name].toolbox_class != td.toolbox_class
193
+ existing = @tool_map[td.tool_name].toolbox_class.name
194
+ raise Toolchest::Error,
195
+ "Duplicate tool name '#{td.tool_name}' in #{td.toolbox_class.name} " \
196
+ "(already defined in #{existing})"
197
+ end
198
+ @tool_map[td.tool_name] = td
199
+ end
200
+ end
201
+
202
+ # --- Scope filtering ---
203
+
204
+ def extract_scopes(auth)
205
+ return nil unless auth
206
+ if auth.respond_to?(:scopes_array)
207
+ auth.scopes_array
208
+ elsif auth.respond_to?(:scopes) && auth.scopes.is_a?(String)
209
+ auth.scopes.split(" ").reject(&:empty?)
210
+ elsif auth.respond_to?(:scopes) && auth.scopes.is_a?(Array)
211
+ auth.scopes
212
+ else
213
+ nil
214
+ end
215
+ end
216
+
217
+ READ_ACTIONS = Set.new(%i[show index list search]).freeze
218
+
219
+ def tool_allowed_by_scopes?(tool_definition, scopes)
220
+ return true if scopes.empty?
221
+
222
+ prefix = tool_definition.toolbox_class.controller_name.split("/").last
223
+ tool_access = tool_definition.access_level ||
224
+ (READ_ACTIONS.include?(tool_definition.method_name) ? :read : :write)
225
+
226
+ scopes.any? { |s|
227
+ scope_prefix, scope_action = s.split(":", 2)
228
+ next false unless scope_prefix == prefix
229
+
230
+ # No action suffix (e.g. "orders") → full access
231
+ next true if scope_action.nil?
232
+
233
+ # "write" scope grants both read and write
234
+ scope_action == tool_access.to_s || scope_action == "write"
235
+ }
236
+ end
237
+
238
+ # --- Request logging ---
239
+
240
+ def log_request_start(definition, arguments, token_hint)
241
+ return unless logger
242
+
243
+ toolbox_name = definition.toolbox_class.name || definition.toolbox_class.controller_name.camelize
244
+ method_name = definition.method_name
245
+
246
+ parts = ["MCP #{toolbox_name}##{method_name}"]
247
+ parts << "(#{token_hint})" if token_hint
248
+ logger.info parts.join(" ")
249
+
250
+ filtered = arguments.respond_to?(:to_h) ? arguments.to_h : arguments
251
+ logger.info " Parameters: #{filtered.inspect}" if filtered.any?
252
+ end
253
+
254
+ def log_request_complete(definition, response, duration)
255
+ return unless logger
256
+
257
+ status = response[:isError] ? "Error" : "OK"
258
+ logger.info "Completed #{status} in #{duration}ms"
259
+ end
260
+
261
+ def extract_token_hint(auth)
262
+ return nil unless auth
263
+ if auth.respond_to?(:token) && auth.token.is_a?(String)
264
+ "#{auth.token[0..8]}..."
265
+ elsif auth.respond_to?(:token_digest) && auth.token_digest.is_a?(String)
266
+ "#{auth.token_digest[0..8]}..."
267
+ else
268
+ nil
269
+ end
270
+ end
271
+
272
+ def logger
273
+ return @logger if defined?(@logger)
274
+ @logger = defined?(Rails) && Rails.respond_to?(:logger) ? Rails.logger : nil
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,61 @@
1
+ require "toolchest"
2
+
3
+ module Toolchest
4
+ module RSpec
5
+ class ToolResponse
6
+ attr_reader :raw
7
+
8
+ def initialize(raw) = @raw = raw
9
+
10
+ def success? = !error?
11
+
12
+ def error? = @raw[:isError] == true
13
+
14
+ def content = @raw[:content] || []
15
+
16
+ def text = content.map { |c| c[:text] }.compact.join("\n")
17
+
18
+ def suggests?(tool_name) = text.include?("Suggested next: call #{tool_name}")
19
+ end
20
+
21
+ module Helpers
22
+ def call_tool(tool_name, params: {}, as: nil)
23
+ Toolchest::Current.set(auth: as) do
24
+ raw = Toolchest.router.dispatch(tool_name, params)
25
+ @_tool_response = ToolResponse.new(raw)
26
+ end
27
+ end
28
+
29
+ def tool_response = @_tool_response
30
+ end
31
+
32
+ module Matchers
33
+ extend ::RSpec::Matchers::DSL
34
+
35
+ matcher :be_success do
36
+ match { |response| response.success? }
37
+ failure_message { "expected tool response to be success, got error: #{actual.text}" }
38
+ end
39
+
40
+ matcher :be_error do
41
+ match { |response| response.error? }
42
+ failure_message { "expected tool response to be an error, but it succeeded" }
43
+ end
44
+
45
+ matcher :include_text do |expected|
46
+ match { |response| response.text.include?(expected) }
47
+ failure_message { "expected tool response text to include #{expected.inspect}, got: #{actual.text}" }
48
+ end
49
+
50
+ matcher :suggest do |tool_name|
51
+ match { |response| response.suggests?(tool_name) }
52
+ failure_message { "expected tool response to suggest #{tool_name}, got: #{actual.text}" }
53
+ end
54
+ end
55
+ end
56
+ end
57
+
58
+ RSpec.configure do |config|
59
+ config.include Toolchest::RSpec::Helpers, type: :toolbox
60
+ config.include Toolchest::RSpec::Matchers, type: :toolbox
61
+ end