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,39 @@
|
|
|
1
|
+
class CreateToolchestOauth < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :toolchest_oauth_applications do |t|
|
|
4
|
+
t.string :name, null: false
|
|
5
|
+
t.string :uid, null: false, index: { unique: true }
|
|
6
|
+
t.string :secret
|
|
7
|
+
t.text :redirect_uri, null: false
|
|
8
|
+
t.string :scopes, null: false, default: ""
|
|
9
|
+
t.boolean :confidential, null: false, default: true
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
create_table :toolchest_oauth_access_grants do |t|
|
|
14
|
+
t.string :resource_owner_id, null: false
|
|
15
|
+
t.references :application, null: false, foreign_key: { to_table: :toolchest_oauth_applications }
|
|
16
|
+
t.string :token_digest, null: false, index: { unique: true }
|
|
17
|
+
t.text :redirect_uri, null: false
|
|
18
|
+
t.string :scopes, null: false, default: ""
|
|
19
|
+
t.string :code_challenge
|
|
20
|
+
t.string :code_challenge_method
|
|
21
|
+
t.string :mount_key, null: false, default: "default"
|
|
22
|
+
t.datetime :expires_at, null: false
|
|
23
|
+
t.datetime :revoked_at
|
|
24
|
+
t.timestamps
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
create_table :toolchest_oauth_access_tokens do |t|
|
|
28
|
+
t.string :resource_owner_id
|
|
29
|
+
t.references :application, null: false, foreign_key: { to_table: :toolchest_oauth_applications }
|
|
30
|
+
t.string :token, null: false, index: { unique: true }
|
|
31
|
+
t.string :refresh_token, index: { unique: true }
|
|
32
|
+
t.string :scopes
|
|
33
|
+
t.string :mount_key, null: false, default: "default"
|
|
34
|
+
t.datetime :expires_at
|
|
35
|
+
t.datetime :revoked_at
|
|
36
|
+
t.timestamps
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateToolchestTokens < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :toolchest_tokens do |t|
|
|
4
|
+
t.string :token_digest, null: false, index: { unique: true }
|
|
5
|
+
t.string :name
|
|
6
|
+
t.string :owner_type
|
|
7
|
+
t.string :owner_id
|
|
8
|
+
t.string :scopes
|
|
9
|
+
t.string :namespace, default: "default"
|
|
10
|
+
t.datetime :expires_at
|
|
11
|
+
t.datetime :last_used_at
|
|
12
|
+
t.datetime :revoked_at
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Toolchest.configure do |config|
|
|
2
|
+
config.server_name = "<%= Rails.application.class.module_parent_name %>"
|
|
3
|
+
# config.server_description = ""
|
|
4
|
+
config.auth = :<%= auth_strategy %>
|
|
5
|
+
config.mount_path = "/mcp"
|
|
6
|
+
<% if auth_strategy == :token -%>
|
|
7
|
+
|
|
8
|
+
# Resolve the token to a user (or anything). Available as auth.resource_owner in toolboxes.
|
|
9
|
+
config.authenticate do |token|
|
|
10
|
+
# User.find(token.owner_id)
|
|
11
|
+
end
|
|
12
|
+
<% elsif auth_strategy == :oauth -%>
|
|
13
|
+
|
|
14
|
+
config.login_path = "/login"
|
|
15
|
+
|
|
16
|
+
# Identify the logged-in user during the OAuth consent screen.
|
|
17
|
+
# Return a user object, or nil to redirect to login_path.
|
|
18
|
+
config.current_user_for_oauth do |request|
|
|
19
|
+
# Example with Devise:
|
|
20
|
+
# request.env["warden"]&.user
|
|
21
|
+
# Example with a session:
|
|
22
|
+
# User.find_by(id: request.session[:user_id])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Resolve the token to a user (or anything). Available as auth.resource_owner in toolboxes.
|
|
26
|
+
config.authenticate do |token|
|
|
27
|
+
# User.find(token.resource_owner_id)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# config.scopes = {
|
|
31
|
+
# "posts:read" => "View blog posts",
|
|
32
|
+
# "posts:write" => "Create and modify blog posts"
|
|
33
|
+
# }
|
|
34
|
+
<% end -%>
|
|
35
|
+
|
|
36
|
+
# Tool naming strategy: :underscored (default), :dotted, :slashed, or a lambda
|
|
37
|
+
# config.tool_naming = :underscored
|
|
38
|
+
|
|
39
|
+
# Filter tools/list by authenticated scopes (default: true)
|
|
40
|
+
# config.filter_tools_by_scope = true
|
|
41
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<%% # app/views/toolchest/oauth/authorizations/new.html.erb %>
|
|
2
|
+
<%% # Ejected from toolchest. Customize to match your app's design. %>
|
|
3
|
+
|
|
4
|
+
<div class="toolchest-consent">
|
|
5
|
+
<h1><%%= @client_name %> wants access to your account</h1>
|
|
6
|
+
<p>Make sure you trust this URL: <code><%%= @redirect_uri %></code></p>
|
|
7
|
+
|
|
8
|
+
<%%= form_tag @authorize_url, method: :post do %>
|
|
9
|
+
<%% @oauth_params&.each do |key, value| %>
|
|
10
|
+
<%%= hidden_field_tag key, value %>
|
|
11
|
+
<%% end %>
|
|
12
|
+
<%%= hidden_field_tag "original_scope", @original_scope %>
|
|
13
|
+
|
|
14
|
+
<%% if @scope_list&.any? %>
|
|
15
|
+
<p>It's asking for:</p>
|
|
16
|
+
<ul>
|
|
17
|
+
<%% @scope_list.each do |s| %>
|
|
18
|
+
<li>
|
|
19
|
+
<%% if @optional %>
|
|
20
|
+
<label>
|
|
21
|
+
<%%= check_box_tag "scope[]", s[:name], true,
|
|
22
|
+
disabled: s[:required], id: "scope_#{s[:name].parameterize}" %>
|
|
23
|
+
<%% if s[:required] %>
|
|
24
|
+
<%%= hidden_field_tag "scope[]", s[:name] %>
|
|
25
|
+
<%% end %>
|
|
26
|
+
<strong><%%= s[:name] %></strong> — <%%= s[:description] %>
|
|
27
|
+
</label>
|
|
28
|
+
<%% else %>
|
|
29
|
+
<%%= hidden_field_tag "scope[]", s[:name] %>
|
|
30
|
+
<strong><%%= s[:name] %></strong> — <%%= s[:description] %>
|
|
31
|
+
<%% end %>
|
|
32
|
+
</li>
|
|
33
|
+
<%% end %>
|
|
34
|
+
</ul>
|
|
35
|
+
<%% end %>
|
|
36
|
+
|
|
37
|
+
<div class="toolchest-consent-actions" style="display: flex; gap: .5rem">
|
|
38
|
+
<%%= submit_tag "Authorize" %>
|
|
39
|
+
</div>
|
|
40
|
+
<%% end %>
|
|
41
|
+
|
|
42
|
+
<%%= form_tag @authorize_url, method: :delete do %>
|
|
43
|
+
<%% @oauth_params&.each do |key, value| %>
|
|
44
|
+
<%%= hidden_field_tag key, value %>
|
|
45
|
+
<%% end %>
|
|
46
|
+
<%%= submit_tag "Deny" %>
|
|
47
|
+
<%% end %>
|
|
48
|
+
</div>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class <%= class_name %>Toolbox < <%= parent_class %>
|
|
2
|
+
<% actions.each_with_index do |action, i| -%>
|
|
3
|
+
<%= "\n" if i > 0 -%>
|
|
4
|
+
tool "TODO: describe <%= action %>" do
|
|
5
|
+
# param :id, :string, "TODO: add params"
|
|
6
|
+
end
|
|
7
|
+
def <%= action %>
|
|
8
|
+
# TODO: implement
|
|
9
|
+
end
|
|
10
|
+
<% end -%>
|
|
11
|
+
<% if actions.empty? -%>
|
|
12
|
+
# tool "Description" do
|
|
13
|
+
# param :id, :string, "The ID"
|
|
14
|
+
# end
|
|
15
|
+
# def show
|
|
16
|
+
# @record = Model.find(params[:id])
|
|
17
|
+
# end
|
|
18
|
+
<% end -%>
|
|
19
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "rails_helper"
|
|
2
|
+
|
|
3
|
+
RSpec.describe <%= class_name %>Toolbox, type: :toolbox do
|
|
4
|
+
<% actions.each_with_index do |action, i| -%>
|
|
5
|
+
<%= "\n" if i > 0 -%>
|
|
6
|
+
describe "#<%= action %>" do
|
|
7
|
+
it "works" do
|
|
8
|
+
call_tool "<%= file_path.gsub("/", "_") %>_<%= action %>",
|
|
9
|
+
params: {}
|
|
10
|
+
|
|
11
|
+
expect(tool_response).to be_success
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
<% end -%>
|
|
15
|
+
<% if actions.empty? -%>
|
|
16
|
+
# describe "#show" do
|
|
17
|
+
# it "works" do
|
|
18
|
+
# call_tool "<%= file_path.gsub("/", "_") %>_show", params: { id: "1" }
|
|
19
|
+
# expect(tool_response).to be_success
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
<% end -%>
|
|
23
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/base"
|
|
3
|
+
|
|
4
|
+
module Toolchest
|
|
5
|
+
module Generators
|
|
6
|
+
class ToolboxGenerator < Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("templates", __dir__)
|
|
8
|
+
|
|
9
|
+
argument :toolbox_name, type: :string
|
|
10
|
+
argument :actions, type: :array, default: []
|
|
11
|
+
|
|
12
|
+
def create_toolbox = template "toolbox.rb.tt", "app/toolboxes/#{file_path}_toolbox.rb"
|
|
13
|
+
|
|
14
|
+
def create_views
|
|
15
|
+
return if actions.empty?
|
|
16
|
+
|
|
17
|
+
actions.each do |action|
|
|
18
|
+
create_file "app/views/toolboxes/#{file_path}/#{action}.json.jb", <<~JB
|
|
19
|
+
{
|
|
20
|
+
# TODO: return data for #{toolbox_name.underscore}##{action}
|
|
21
|
+
}
|
|
22
|
+
JB
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def create_spec = template "toolbox_spec.rb.tt", "spec/toolboxes/#{file_path}_toolbox_spec.rb"
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def file_path = toolbox_name.underscore
|
|
31
|
+
|
|
32
|
+
def class_name = toolbox_name.camelize
|
|
33
|
+
|
|
34
|
+
def parent_class
|
|
35
|
+
if file_path.include?("/")
|
|
36
|
+
namespace = file_path.split("/")[0..-2].map(&:camelize).join("::")
|
|
37
|
+
"#{namespace}::ApplicationToolbox"
|
|
38
|
+
else
|
|
39
|
+
"ApplicationToolbox"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require "action_dispatch"
|
|
2
|
+
|
|
3
|
+
module Toolchest
|
|
4
|
+
# Rack app for a named mount. Used for multi-mount:
|
|
5
|
+
# mount Toolchest.app(:admin) => "/admin-mcp"
|
|
6
|
+
#
|
|
7
|
+
# Handles OAuth routes (authorize, token, register, authorized_applications)
|
|
8
|
+
# and delegates everything else to the MCP transport (RackApp).
|
|
9
|
+
class App
|
|
10
|
+
attr_reader :mount_key
|
|
11
|
+
|
|
12
|
+
def initialize(mount_key = :default)
|
|
13
|
+
@mount_key = mount_key.to_sym
|
|
14
|
+
@router = build_action_dispatch_router
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
Engine.ensure_initialized!
|
|
19
|
+
env["toolchest.mount_key"] = @mount_key.to_s
|
|
20
|
+
@router.call(env)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def build_action_dispatch_router
|
|
26
|
+
mk = @mount_key
|
|
27
|
+
endpoint = Endpoint.new
|
|
28
|
+
|
|
29
|
+
ActionDispatch::Routing::RouteSet.new.tap do |routes|
|
|
30
|
+
routes.draw do
|
|
31
|
+
get "oauth/authorize", to: "toolchest/oauth/authorizations#new"
|
|
32
|
+
post "oauth/authorize", to: "toolchest/oauth/authorizations#create"
|
|
33
|
+
delete "oauth/authorize", to: "toolchest/oauth/authorizations#deny"
|
|
34
|
+
post "oauth/token", to: "toolchest/oauth/tokens#create"
|
|
35
|
+
post "oauth/register", to: "toolchest/oauth/registrations#create"
|
|
36
|
+
|
|
37
|
+
resources :oauth_authorized_applications, only: [:index, :destroy],
|
|
38
|
+
path: "oauth/authorized_applications",
|
|
39
|
+
controller: "toolchest/oauth/authorized_applications"
|
|
40
|
+
|
|
41
|
+
match "/", to: endpoint, via: [:get, :post, :delete]
|
|
42
|
+
match "/*path", to: endpoint, via: [:get, :post, :delete]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
module Auth
|
|
3
|
+
class Base
|
|
4
|
+
def authenticate(request) = raise NotImplementedError
|
|
5
|
+
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def extract_bearer_token(request)
|
|
9
|
+
auth_header = request.env["HTTP_AUTHORIZATION"] || ""
|
|
10
|
+
match = auth_header.match(/\ABearer\s+(.+)\z/i)
|
|
11
|
+
match&.[](1)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
module Auth
|
|
3
|
+
class OAuth < Base
|
|
4
|
+
def initialize(mount_key = :default)
|
|
5
|
+
@mount_key = mount_key.to_s
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def authenticate(request)
|
|
9
|
+
token_string = extract_bearer_token(request)
|
|
10
|
+
return nil unless token_string
|
|
11
|
+
|
|
12
|
+
token = Toolchest::OauthAccessToken.find_by_token(token_string, mount_key: @mount_key)
|
|
13
|
+
return nil unless token
|
|
14
|
+
|
|
15
|
+
config = Toolchest.configuration(@mount_key.to_sym)
|
|
16
|
+
owner = if config.send(:instance_variable_get, :@authenticate_block)
|
|
17
|
+
config.authenticate_with(token)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
AuthContext.new(
|
|
21
|
+
resource_owner: owner,
|
|
22
|
+
scopes: token.scopes_array,
|
|
23
|
+
token: token
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module Toolchest
|
|
4
|
+
module Auth
|
|
5
|
+
class Token < Base
|
|
6
|
+
def authenticate(request)
|
|
7
|
+
token_string = extract_bearer_token(request)
|
|
8
|
+
return nil unless token_string
|
|
9
|
+
|
|
10
|
+
token_record = find_token(token_string)
|
|
11
|
+
return nil unless token_record
|
|
12
|
+
|
|
13
|
+
scopes = if token_record.respond_to?(:scopes_array)
|
|
14
|
+
token_record.scopes_array
|
|
15
|
+
elsif token_record.respond_to?(:scopes)
|
|
16
|
+
Array(token_record.scopes).flat_map { |s| s.split(" ") }.reject(&:empty?)
|
|
17
|
+
else
|
|
18
|
+
[]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config = Toolchest.configuration
|
|
22
|
+
owner = if config.respond_to?(:authenticate_with) && config.send(:instance_variable_get, :@authenticate_block)
|
|
23
|
+
config.authenticate_with(token_record)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
AuthContext.new(
|
|
27
|
+
resource_owner: owner,
|
|
28
|
+
scopes: scopes,
|
|
29
|
+
token: token_record
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def find_token(token_string)
|
|
36
|
+
env_token = find_env_token(token_string)
|
|
37
|
+
return env_token if env_token
|
|
38
|
+
|
|
39
|
+
find_db_token(token_string)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def find_env_token(token_string)
|
|
43
|
+
expected = ENV["TOOLCHEST_TOKEN"]
|
|
44
|
+
return nil unless expected
|
|
45
|
+
return nil unless secure_compare(token_string, expected)
|
|
46
|
+
|
|
47
|
+
owner = ENV["TOOLCHEST_TOKEN_OWNER"]
|
|
48
|
+
EnvTokenRecord.new(token_string, owner)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def find_db_token(token_string)
|
|
52
|
+
return nil unless defined?(Toolchest::Token) && Toolchest::Token.table_exists?
|
|
53
|
+
|
|
54
|
+
token = Toolchest::Token.find_by_raw_token(token_string)
|
|
55
|
+
return nil unless token
|
|
56
|
+
|
|
57
|
+
token.update_column(:last_used_at, Time.current)
|
|
58
|
+
token
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def secure_compare(a, b) = ActiveSupport::SecurityUtils.secure_compare(a, b)
|
|
62
|
+
|
|
63
|
+
EnvTokenRecord = Struct.new(:token, :owner_id) do
|
|
64
|
+
def owner_type
|
|
65
|
+
type, _ = owner_id&.split(":", 2)
|
|
66
|
+
type
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def scopes = ENV.fetch("TOOLCHEST_TOKEN_SCOPES", "").split(" ").reject(&:empty?)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
class AuthContext
|
|
3
|
+
attr_reader :resource_owner, :scopes, :token
|
|
4
|
+
|
|
5
|
+
def initialize(resource_owner:, scopes:, token:)
|
|
6
|
+
@resource_owner = resource_owner
|
|
7
|
+
@scopes = scopes
|
|
8
|
+
@token = token
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def scopes_array = @scopes
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
class Configuration
|
|
3
|
+
VALID_AUTH_STRATEGIES = %i[none token oauth].freeze
|
|
4
|
+
|
|
5
|
+
attr_accessor :tool_naming, :filter_tools_by_scope,
|
|
6
|
+
:server_name, :server_version, :server_description, :server_instructions,
|
|
7
|
+
:scopes, :login_path, :additional_view_paths,
|
|
8
|
+
:access_token_expires_in, :toolboxes, :toolbox_module,
|
|
9
|
+
:mount_key, :mount_path,
|
|
10
|
+
:optional_scopes, :required_scopes
|
|
11
|
+
attr_reader :auth
|
|
12
|
+
|
|
13
|
+
def initialize(mount_key = :default)
|
|
14
|
+
@mount_key = mount_key.to_sym
|
|
15
|
+
@auth = :none
|
|
16
|
+
@tool_naming = :underscored
|
|
17
|
+
@filter_tools_by_scope = true
|
|
18
|
+
@server_name = nil
|
|
19
|
+
@server_version = Toolchest::VERSION
|
|
20
|
+
@scopes = {}
|
|
21
|
+
@login_path = "/login"
|
|
22
|
+
@authenticate_block = nil
|
|
23
|
+
@optional_scopes = false
|
|
24
|
+
@required_scopes = []
|
|
25
|
+
@allowed_scopes_for_block = nil
|
|
26
|
+
@additional_view_paths = []
|
|
27
|
+
@access_token_expires_in = 7200
|
|
28
|
+
@toolboxes = nil
|
|
29
|
+
@toolbox_module = nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def auth=(value)
|
|
33
|
+
if value.respond_to?(:authenticate)
|
|
34
|
+
@auth = value
|
|
35
|
+
elsif value.respond_to?(:to_sym)
|
|
36
|
+
sym = value.to_sym
|
|
37
|
+
unless VALID_AUTH_STRATEGIES.include?(sym)
|
|
38
|
+
raise Toolchest::Error,
|
|
39
|
+
"Invalid auth strategy :#{value}. Valid options: #{VALID_AUTH_STRATEGIES.map { |s| ":#{s}" }.join(', ')}, or an object responding to #authenticate(request)"
|
|
40
|
+
end
|
|
41
|
+
@auth = sym
|
|
42
|
+
else
|
|
43
|
+
raise Toolchest::Error,
|
|
44
|
+
"Auth must be a symbol (:none, :token, :oauth) or an object responding to #authenticate(request)"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def authenticate(&block) = @authenticate_block = block
|
|
49
|
+
|
|
50
|
+
def authenticate_with(token)
|
|
51
|
+
return nil unless @authenticate_block
|
|
52
|
+
@authenticate_block.call(token)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def allowed_scopes_for(&block)
|
|
56
|
+
if block
|
|
57
|
+
@allowed_scopes_for_block = block
|
|
58
|
+
else
|
|
59
|
+
@allowed_scopes_for_block
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def resolve_allowed_scopes(user, scopes)
|
|
64
|
+
@allowed_scopes_for_block ? @allowed_scopes_for_block.call(user, scopes) : scopes
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def current_user_for_oauth(&block)
|
|
68
|
+
if block
|
|
69
|
+
@current_user_for_oauth_block = block
|
|
70
|
+
else
|
|
71
|
+
@current_user_for_oauth_block
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_current_user(request)
|
|
76
|
+
return nil unless @current_user_for_oauth_block
|
|
77
|
+
@current_user_for_oauth_block.call(request)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def resolved_server_name = @server_name || (defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : "Toolchest")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
# Rack app for MCP protocol requests (JSON-RPC over HTTP).
|
|
3
|
+
# OAuth endpoints are handled by Rails controllers via engine routes.
|
|
4
|
+
class Endpoint
|
|
5
|
+
def call(env)
|
|
6
|
+
Engine.ensure_initialized!
|
|
7
|
+
|
|
8
|
+
mount_key = (env["toolchest.mount_key"] || "default").to_sym
|
|
9
|
+
app = Toolchest.router(mount_key).rack_app ||= RackApp.new(mount_key: mount_key)
|
|
10
|
+
app.call(env)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
|
|
3
|
+
module Toolchest
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Toolchest
|
|
6
|
+
|
|
7
|
+
rake_tasks do
|
|
8
|
+
load File.expand_path("tasks/toolchest.rake", __dir__)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
initializer "toolchest.autoload_paths", before: :set_autoload_paths do |app|
|
|
12
|
+
app.config.autoload_paths += [Rails.root.join("app", "toolboxes").to_s]
|
|
13
|
+
app.config.eager_load_paths += [Rails.root.join("app", "toolboxes").to_s]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
initializer "toolchest.setup" do
|
|
17
|
+
config.after_initialize do
|
|
18
|
+
Toolchest::Engine.ensure_initialized!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
config.to_prepare do
|
|
22
|
+
Toolchest::Engine.reload!
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def ensure_initialized!
|
|
28
|
+
return if @initialized
|
|
29
|
+
|
|
30
|
+
require "mcp"
|
|
31
|
+
|
|
32
|
+
discover_and_assign_toolboxes!
|
|
33
|
+
|
|
34
|
+
@initialized = true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reload!
|
|
38
|
+
@initialized = false
|
|
39
|
+
Toolchest.reset_routers!
|
|
40
|
+
ensure_initialized!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def discover_and_assign_toolboxes!
|
|
46
|
+
return unless defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
47
|
+
|
|
48
|
+
toolboxes_path = Rails.root.join("app", "toolboxes")
|
|
49
|
+
return unless toolboxes_path.exist?
|
|
50
|
+
|
|
51
|
+
all_toolboxes = []
|
|
52
|
+
|
|
53
|
+
Dir[toolboxes_path.join("**", "*_toolbox.rb")].each do |file|
|
|
54
|
+
class_name = file
|
|
55
|
+
.sub(toolboxes_path.to_s + "/", "")
|
|
56
|
+
.sub(/\.rb$/, "")
|
|
57
|
+
.camelize
|
|
58
|
+
|
|
59
|
+
next if class_name == "ApplicationToolbox"
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
klass = class_name.constantize
|
|
63
|
+
all_toolboxes << klass if klass < Toolchest::Toolbox
|
|
64
|
+
rescue NameError
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Assign toolboxes to mounts based on config
|
|
69
|
+
Toolchest.mount_keys.each do |mount_key|
|
|
70
|
+
cfg = Toolchest.configuration(mount_key)
|
|
71
|
+
router = Toolchest.router(mount_key)
|
|
72
|
+
|
|
73
|
+
assigned = if cfg.toolboxes
|
|
74
|
+
# Explicit list (supports strings for lazy loading)
|
|
75
|
+
cfg.toolboxes.map { |t| t.is_a?(String) ? t.constantize : t }
|
|
76
|
+
elsif cfg.toolbox_module
|
|
77
|
+
# Module convention
|
|
78
|
+
all_toolboxes.select { |t| t.name&.start_with?("#{cfg.toolbox_module}::") }
|
|
79
|
+
else
|
|
80
|
+
# Default: all toolboxes (only valid for single-mount)
|
|
81
|
+
all_toolboxes
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
assigned.each { |t| router.register(t) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# If no mounts configured yet, register all to :default
|
|
88
|
+
if Toolchest.mount_keys.empty?
|
|
89
|
+
all_toolboxes.each { |t| Toolchest.router(:default).register(t) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
module Naming
|
|
3
|
+
class << self
|
|
4
|
+
def generate(toolbox_class, method_name, strategy = :underscored)
|
|
5
|
+
prefix = toolbox_prefix(toolbox_class)
|
|
6
|
+
|
|
7
|
+
case strategy
|
|
8
|
+
when :underscored
|
|
9
|
+
"#{prefix}_#{method_name}"
|
|
10
|
+
when :dotted
|
|
11
|
+
"#{prefix}.#{method_name}"
|
|
12
|
+
when :slashed
|
|
13
|
+
"#{prefix}/#{method_name}"
|
|
14
|
+
when Proc
|
|
15
|
+
strategy.call(prefix, method_name.to_s)
|
|
16
|
+
else
|
|
17
|
+
"#{prefix}_#{method_name}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def toolbox_prefix(toolbox_class)
|
|
24
|
+
name = toolbox_class.name || toolbox_class.to_s
|
|
25
|
+
name.underscore
|
|
26
|
+
.chomp("_toolbox")
|
|
27
|
+
.gsub("/", "_")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module ActionDispatch
|
|
2
|
+
module Routing
|
|
3
|
+
class Mapper
|
|
4
|
+
# Mount well-known OAuth discovery routes at the app root.
|
|
5
|
+
# MCP clients discover OAuth endpoints via these paths.
|
|
6
|
+
#
|
|
7
|
+
# mount Toolchest::Engine => "/mcp"
|
|
8
|
+
# toolchest_oauth
|
|
9
|
+
#
|
|
10
|
+
# For multi-mount, call once — the (/*rest) suffix lets the
|
|
11
|
+
# MetadataController return the correct endpoints per mount:
|
|
12
|
+
# /.well-known/oauth-authorization-server/mcp → /mcp mount
|
|
13
|
+
# /.well-known/oauth-authorization-server/admin-mcp → /admin-mcp mount
|
|
14
|
+
#
|
|
15
|
+
def toolchest_oauth(default_mount: nil)
|
|
16
|
+
Toolchest.default_oauth_mount = default_mount.to_sym if default_mount
|
|
17
|
+
|
|
18
|
+
get "/.well-known/oauth-authorization-server(/*rest)",
|
|
19
|
+
to: "toolchest/oauth/metadata#authorization_server"
|
|
20
|
+
get "/.well-known/oauth-protected-resource(/*rest)",
|
|
21
|
+
to: "toolchest/oauth/metadata#protected_resource"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|