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