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,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
|