toolchest 0.3.6 → 0.4.0
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 +4 -4
- data/README.md +120 -1
- data/app/controllers/toolchest/application_controller.rb +9 -0
- data/app/controllers/toolchest/oauth/authorizations_controller.rb +19 -0
- data/app/controllers/toolchest/oauth/metadata_controller.rb +1 -6
- data/app/controllers/toolchest/oauth/registrations_controller.rb +24 -0
- data/app/controllers/toolchest/oauth/tokens_controller.rb +15 -5
- data/app/models/toolchest/oauth_access_grant.rb +5 -1
- data/app/models/toolchest/oauth_access_token.rb +7 -0
- data/lib/generators/toolchest/auth_generator.rb +1 -1
- data/lib/toolchest/app.rb +6 -0
- data/lib/toolchest/auth/base.rb +1 -1
- data/lib/toolchest/configuration.rb +24 -1
- data/lib/toolchest/current.rb +1 -1
- data/lib/toolchest/rack_app.rb +129 -28
- data/lib/toolchest/router.rb +11 -0
- data/lib/toolchest/tool_definition.rb +6 -2
- data/lib/toolchest/toolbox.rb +67 -14
- data/lib/toolchest/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80dd8a25c340a7918653dc8e63fa0d899a23e10306c87d3143456688891de9d7
|
|
4
|
+
data.tar.gz: 0f2416b46244ff3e03aa28636e81d126e18303f8385bea0053b4e90c04c44551
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae68328956e828a05ae03e662d95f8af5052de7d8a456a06d99c76a2989665f67b236003ef990b9491cec6e7b85310dabfd8e5bdc3a35584189c0710ebf48c71
|
|
7
|
+
data.tar.gz: 446511d71d09c9d1c02728176d559d478ff15df18ce317b118bd1d1e6fe3f9e291cc10ba71c7a1fc626493c991bbdb5c57f74321f288715bad98384e562cbe43
|
data/README.md
CHANGED
|
@@ -278,6 +278,79 @@ tool "Export data", access: :read, annotations: { openWorldHint: true } do
|
|
|
278
278
|
end
|
|
279
279
|
```
|
|
280
280
|
|
|
281
|
+
### Elicitation
|
|
282
|
+
|
|
283
|
+
Ask the client's user for input via a form. Returns the result hash from the client.
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
tool "Confirm shipping address" do
|
|
287
|
+
param :order_id, :string, "Order ID"
|
|
288
|
+
end
|
|
289
|
+
def confirm_address
|
|
290
|
+
@order = Order.find(params[:order_id])
|
|
291
|
+
result = mcp_elicit("Please confirm the shipping address: #{@order.address}",
|
|
292
|
+
schema: {
|
|
293
|
+
type: "object",
|
|
294
|
+
properties: {
|
|
295
|
+
confirmed: { type: "boolean", description: "Address is correct?" }
|
|
296
|
+
},
|
|
297
|
+
required: ["confirmed"]
|
|
298
|
+
})
|
|
299
|
+
halt error: "Cancelled" unless result["action"] == "accept"
|
|
300
|
+
@order.confirm_address!
|
|
301
|
+
render :show
|
|
302
|
+
end
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Raises `Toolchest::Error` if the client doesn't support elicitation.
|
|
306
|
+
|
|
307
|
+
### Cancellation
|
|
308
|
+
|
|
309
|
+
Check if the client cancelled the current request. Useful in long-running loops:
|
|
310
|
+
|
|
311
|
+
```ruby
|
|
312
|
+
tool "Import customers" do
|
|
313
|
+
param :file_url, :string, "CSV URL"
|
|
314
|
+
end
|
|
315
|
+
def import
|
|
316
|
+
rows = CSV.parse(download(params[:file_url]))
|
|
317
|
+
rows.each_with_index do |row, i|
|
|
318
|
+
mcp_raise_if_cancelled!
|
|
319
|
+
Customer.create!(row.to_h)
|
|
320
|
+
mcp_progress i + 1, total: rows.size
|
|
321
|
+
end
|
|
322
|
+
render text: "Imported #{rows.size} customers"
|
|
323
|
+
end
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
`mcp_cancelled?` returns a boolean. `mcp_raise_if_cancelled!` raises `MCP::CancelledError` to bail out immediately.
|
|
327
|
+
|
|
328
|
+
### Tool titles and output schemas
|
|
329
|
+
|
|
330
|
+
`title:` sets a human-readable display name. `output:` declares the tool's output JSON schema:
|
|
331
|
+
|
|
332
|
+
```ruby
|
|
333
|
+
tool "Look up an order", title: "Order Lookup",
|
|
334
|
+
output: { type: "object", properties: { id: { type: "string" }, status: { type: "string" } } } do
|
|
335
|
+
param :order_id, :string, "Order ID"
|
|
336
|
+
end
|
|
337
|
+
def show
|
|
338
|
+
# ...
|
|
339
|
+
end
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`title:` also works on `resource` and `prompt`:
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
resource "orders://schema", name: "Order schema", title: "Schema: Orders" do
|
|
346
|
+
# ...
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
prompt "debug-order", title: "Debug Order" do |order_id:|
|
|
350
|
+
# ...
|
|
351
|
+
end
|
|
352
|
+
```
|
|
353
|
+
|
|
281
354
|
### Logging
|
|
282
355
|
|
|
283
356
|
```ruby
|
|
@@ -300,6 +373,51 @@ end
|
|
|
300
373
|
|
|
301
374
|
This shows up in the MCP initialize response. `server_name` and `server_description` are also available.
|
|
302
375
|
|
|
376
|
+
### Transport security
|
|
377
|
+
|
|
378
|
+
Configure host validation, origin checking, and session limits:
|
|
379
|
+
|
|
380
|
+
```ruby
|
|
381
|
+
Toolchest.configure do |config|
|
|
382
|
+
config.allowed_hosts = ["myapp.com", "mcp.myapp.com"]
|
|
383
|
+
config.allowed_origins = ["https://myapp.com"]
|
|
384
|
+
config.dns_rebinding_protection = true
|
|
385
|
+
config.session_idle_timeout = 30.minutes
|
|
386
|
+
config.max_sessions = 100
|
|
387
|
+
config.stateless = false
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`dns_rebinding_protection` validates the `Host` header against `allowed_hosts` to prevent DNS rebinding attacks. `stateless` disables server-side session state entirely (every request is standalone). `session_idle_timeout` evicts idle sessions, `max_sessions` caps total concurrent sessions.
|
|
392
|
+
|
|
393
|
+
### Caching and pagination
|
|
394
|
+
|
|
395
|
+
Control list pagination and cache behavior for resources:
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
Toolchest.configure do |config|
|
|
399
|
+
config.page_size = 50
|
|
400
|
+
config.cache_ttl = 5.minutes
|
|
401
|
+
config.cache_scope = "public"
|
|
402
|
+
end
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
`page_size` sets the default page size for paginated list responses. `cache_ttl` and `cache_scope` control how long clients can cache list and read results. `cache_scope` is `"public"` (shared caches allowed) or `"private"` (client-only).
|
|
406
|
+
|
|
407
|
+
### Instrumentation
|
|
408
|
+
|
|
409
|
+
Every tool dispatch fires an `ActiveSupport::Notifications` event:
|
|
410
|
+
|
|
411
|
+
```ruby
|
|
412
|
+
ActiveSupport::Notifications.subscribe("dispatch.toolchest") do |*args|
|
|
413
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
414
|
+
Rails.logger.info "[toolchest] #{event.payload[:tool]} " \
|
|
415
|
+
"#{event.payload[:duration]}ms error=#{event.payload[:error]}"
|
|
416
|
+
end
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Payload includes `:tool`, `:toolbox`, `:action`, `:arguments`, `:duration` (ms), and `:error` (boolean).
|
|
420
|
+
|
|
303
421
|
## Auth
|
|
304
422
|
|
|
305
423
|
Three built-in strategies, or bring your own. Default is `:none`.
|
|
@@ -630,10 +748,11 @@ end
|
|
|
630
748
|
|
|
631
749
|
- **Rate limiting**: Toolchest doesn't include rate limiting. Use [rack-attack](https://github.com/rack/rack-attack) or your reverse proxy to protect token and registration endpoints.
|
|
632
750
|
- **HTTPS**: OAuth endpoints should always run behind TLS in production.
|
|
751
|
+
- **DNS rebinding**: Built-in protection via `config.dns_rebinding_protection` and `config.allowed_hosts`. See [Transport security](#transport-security).
|
|
633
752
|
|
|
634
753
|
## Internals
|
|
635
754
|
|
|
636
|
-
Transport is the [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) (`mcp` gem).
|
|
755
|
+
Transport is the [MCP Ruby SDK](https://github.com/modelcontextprotocol/ruby-sdk) (`mcp` gem, >= 1.0).
|
|
637
756
|
|
|
638
757
|
OAuth provider is cribbed from [Doorkeeper](https://github.com/doorkeeper-gem/doorkeeper). Same table layout, same controller shapes. Not a dependency, just stole the design.
|
|
639
758
|
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
module Toolchest
|
|
2
2
|
class ApplicationController < Toolchest.base_controller.constantize
|
|
3
|
+
protect_from_forgery with: :exception
|
|
4
|
+
before_action :set_frame_options
|
|
5
|
+
|
|
3
6
|
helper Toolchest::RouteDelegation if Toolchest.delegate_route_helpers
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def set_frame_options
|
|
11
|
+
response.headers["X-Frame-Options"] = "DENY"
|
|
12
|
+
end
|
|
4
13
|
end
|
|
5
14
|
end
|
|
@@ -133,6 +133,25 @@ module Toolchest
|
|
|
133
133
|
|
|
134
134
|
if params[:redirect_uri].present? && !@application.redirect_uri_matches?(params[:redirect_uri])
|
|
135
135
|
render json: { error: "invalid_redirect_uri" }, status: :bad_request
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
if params[:response_type].present? && params[:response_type] != "code"
|
|
140
|
+
redirect_url = build_redirect(params[:redirect_uri],
|
|
141
|
+
error: "unsupported_response_type",
|
|
142
|
+
state: params[:state]
|
|
143
|
+
)
|
|
144
|
+
redirect_to redirect_url, allow_other_host: true
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if params[:code_challenge_method].present? && params[:code_challenge_method] != "S256"
|
|
149
|
+
redirect_url = build_redirect(params[:redirect_uri],
|
|
150
|
+
error: "invalid_request",
|
|
151
|
+
error_description: "Only S256 code_challenge_method is supported",
|
|
152
|
+
state: params[:state]
|
|
153
|
+
)
|
|
154
|
+
redirect_to redirect_url, allow_other_host: true
|
|
136
155
|
end
|
|
137
156
|
end
|
|
138
157
|
|
|
@@ -37,14 +37,9 @@ module Toolchest
|
|
|
37
37
|
# Returns [mount_path, config] or renders 404 and returns [nil, nil].
|
|
38
38
|
def resolve_mount
|
|
39
39
|
if params[:rest].present?
|
|
40
|
-
# Suffixed path (RFC 8414) — must match a configured mount exactly.
|
|
41
40
|
path = "/#{params[:rest]}"
|
|
42
41
|
key = Toolchest.mount_keys.find { |k| Toolchest.configuration(k).mount_path == path }
|
|
43
|
-
|
|
44
|
-
head :not_found
|
|
45
|
-
return [nil, nil]
|
|
46
|
-
end
|
|
47
|
-
return [path, Toolchest.configuration(key)]
|
|
42
|
+
return [path, Toolchest.configuration(key)] if key
|
|
48
43
|
end
|
|
49
44
|
|
|
50
45
|
# No suffix (e.g. Cursor). Use default_oauth_mount if set.
|
|
@@ -31,6 +31,30 @@ module Toolchest
|
|
|
31
31
|
}, status: :bad_request
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
dangerous_schemes = %w[javascript data vbscript].freeze
|
|
35
|
+
uris.each do |u|
|
|
36
|
+
begin
|
|
37
|
+
parsed = URI.parse(u)
|
|
38
|
+
unless parsed.scheme && parsed.host
|
|
39
|
+
return render json: {
|
|
40
|
+
error: "invalid_client_metadata",
|
|
41
|
+
error_description: "Redirect URI must have a scheme and host"
|
|
42
|
+
}, status: :bad_request
|
|
43
|
+
end
|
|
44
|
+
if dangerous_schemes.include?(parsed.scheme.downcase)
|
|
45
|
+
return render json: {
|
|
46
|
+
error: "invalid_client_metadata",
|
|
47
|
+
error_description: "Redirect URI scheme is not allowed"
|
|
48
|
+
}, status: :bad_request
|
|
49
|
+
end
|
|
50
|
+
rescue URI::InvalidURIError
|
|
51
|
+
return render json: {
|
|
52
|
+
error: "invalid_client_metadata",
|
|
53
|
+
error_description: "Redirect URI is not a valid URI"
|
|
54
|
+
}, status: :bad_request
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
34
58
|
application = Toolchest::OauthApplication.new(
|
|
35
59
|
name: name,
|
|
36
60
|
redirect_uri: uris.join("\n"),
|
|
@@ -21,7 +21,7 @@ module Toolchest
|
|
|
21
21
|
def toolchest_config = Toolchest.configuration(mount_key.to_sym)
|
|
22
22
|
|
|
23
23
|
def handle_authorization_code
|
|
24
|
-
grant = Toolchest::OauthAccessGrant.find_by_code(params[:code])
|
|
24
|
+
grant = Toolchest::OauthAccessGrant.find_by_code(params[:code], mount_key: mount_key)
|
|
25
25
|
|
|
26
26
|
unless grant
|
|
27
27
|
return error_response("invalid_grant", "Authorization code not found or expired")
|
|
@@ -29,7 +29,11 @@ module Toolchest
|
|
|
29
29
|
|
|
30
30
|
app = grant.application
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
unless params[:client_id].present?
|
|
33
|
+
return error_response("invalid_request", "client_id is required")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if app.uid != params[:client_id]
|
|
33
37
|
return error_response("invalid_client", "Client ID mismatch")
|
|
34
38
|
end
|
|
35
39
|
|
|
@@ -37,8 +41,8 @@ module Toolchest
|
|
|
37
41
|
return error_response("invalid_grant", "Redirect URI mismatch")
|
|
38
42
|
end
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
return error_response("invalid_request", "PKCE required
|
|
44
|
+
unless grant.uses_pkce?
|
|
45
|
+
return error_response("invalid_request", "PKCE is required")
|
|
42
46
|
end
|
|
43
47
|
|
|
44
48
|
unless grant.verify_pkce(params[:code_verifier])
|
|
@@ -69,7 +73,13 @@ module Toolchest
|
|
|
69
73
|
return error_response("invalid_grant", "Refresh token invalid or expired")
|
|
70
74
|
end
|
|
71
75
|
|
|
72
|
-
old_token.
|
|
76
|
+
if params[:client_id].present? && old_token.application&.uid != params[:client_id]
|
|
77
|
+
return error_response("invalid_client", "Client ID mismatch")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
unless old_token.revoke_atomically!
|
|
81
|
+
return error_response("invalid_grant", "Refresh token already used")
|
|
82
|
+
end
|
|
73
83
|
|
|
74
84
|
token = Toolchest::OauthAccessToken.create_for(
|
|
75
85
|
application: old_token.application,
|
|
@@ -65,7 +65,11 @@ module Toolchest
|
|
|
65
65
|
grant
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def find_by_code(raw_code
|
|
68
|
+
def find_by_code(raw_code, mount_key: nil)
|
|
69
|
+
scope = active.where(token_digest: Digest::SHA256.hexdigest(raw_code))
|
|
70
|
+
scope = scope.where(mount_key: mount_key) if mount_key
|
|
71
|
+
scope.first
|
|
72
|
+
end
|
|
69
73
|
end
|
|
70
74
|
|
|
71
75
|
def raw_code = @raw_code
|
|
@@ -18,6 +18,13 @@ module Toolchest
|
|
|
18
18
|
|
|
19
19
|
def revoke! = update!(revoked_at: Time.current)
|
|
20
20
|
|
|
21
|
+
# Atomic revocation — returns true only if THIS call revoked the token.
|
|
22
|
+
# Prevents race conditions where two concurrent refresh exchanges both
|
|
23
|
+
# find the token active and both mint new token pairs.
|
|
24
|
+
def revoke_atomically!
|
|
25
|
+
self.class.where(id: id, revoked_at: nil).update_all(revoked_at: Time.current) > 0
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
def accessible? = !revoked? && !expired?
|
|
22
29
|
|
|
23
30
|
def scopes_array = (scopes || "").split(" ").reject(&:empty?)
|
data/lib/toolchest/app.rb
CHANGED
|
@@ -17,6 +17,12 @@ module Toolchest
|
|
|
17
17
|
def call(env)
|
|
18
18
|
Engine.ensure_initialized!
|
|
19
19
|
env["toolchest.mount_key"] = @mount_key.to_s
|
|
20
|
+
|
|
21
|
+
cfg = Toolchest.configuration(@mount_key)
|
|
22
|
+
if cfg.mount_path.nil? && env["SCRIPT_NAME"].present?
|
|
23
|
+
cfg.mount_path = env["SCRIPT_NAME"]
|
|
24
|
+
end
|
|
25
|
+
|
|
20
26
|
@router.call(env)
|
|
21
27
|
end
|
|
22
28
|
|
data/lib/toolchest/auth/base.rb
CHANGED
|
@@ -7,7 +7,10 @@ module Toolchest
|
|
|
7
7
|
:scopes, :login_path, :additional_view_paths,
|
|
8
8
|
:access_token_expires_in, :toolboxes, :toolbox_module,
|
|
9
9
|
:mount_key, :mount_path,
|
|
10
|
-
:optional_scopes, :required_scopes
|
|
10
|
+
:optional_scopes, :required_scopes,
|
|
11
|
+
:allowed_hosts, :allowed_origins, :dns_rebinding_protection,
|
|
12
|
+
:session_idle_timeout, :max_sessions, :stateless,
|
|
13
|
+
:page_size, :cache_ttl, :cache_scope
|
|
11
14
|
attr_reader :auth
|
|
12
15
|
|
|
13
16
|
def initialize(mount_key = :default)
|
|
@@ -28,6 +31,15 @@ module Toolchest
|
|
|
28
31
|
@access_token_expires_in = 7200
|
|
29
32
|
@toolboxes = nil
|
|
30
33
|
@toolbox_module = nil
|
|
34
|
+
@allowed_hosts = nil
|
|
35
|
+
@allowed_origins = nil
|
|
36
|
+
@dns_rebinding_protection = nil
|
|
37
|
+
@session_idle_timeout = nil
|
|
38
|
+
@max_sessions = nil
|
|
39
|
+
@stateless = false
|
|
40
|
+
@page_size = nil
|
|
41
|
+
@cache_ttl = nil
|
|
42
|
+
@cache_scope = nil
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
def auth=(value)
|
|
@@ -92,5 +104,16 @@ module Toolchest
|
|
|
92
104
|
end
|
|
93
105
|
|
|
94
106
|
def resolved_server_name = @server_name || (defined?(Rails) && Rails.application ? Rails.application.class.module_parent_name : "Toolchest")
|
|
107
|
+
|
|
108
|
+
def transport_options
|
|
109
|
+
opts = {}
|
|
110
|
+
opts[:allowed_hosts] = @allowed_hosts if @allowed_hosts
|
|
111
|
+
opts[:allowed_origins] = @allowed_origins if @allowed_origins
|
|
112
|
+
opts[:dns_rebinding_protection] = @dns_rebinding_protection unless @dns_rebinding_protection.nil?
|
|
113
|
+
opts[:session_idle_timeout] = @session_idle_timeout if @session_idle_timeout
|
|
114
|
+
opts[:max_sessions] = @max_sessions if @max_sessions
|
|
115
|
+
opts[:stateless] = @stateless if @stateless
|
|
116
|
+
opts
|
|
117
|
+
end
|
|
95
118
|
end
|
|
96
119
|
end
|
data/lib/toolchest/current.rb
CHANGED
|
@@ -2,6 +2,6 @@ require "active_support/current_attributes"
|
|
|
2
2
|
|
|
3
3
|
module Toolchest
|
|
4
4
|
class Current < ActiveSupport::CurrentAttributes
|
|
5
|
-
attribute :auth, :mount_key, :mcp_session, :mcp_request_id, :mcp_progress_token
|
|
5
|
+
attribute :auth, :mount_key, :mcp_server_context, :mcp_session, :mcp_request_id, :mcp_progress_token
|
|
6
6
|
end
|
|
7
7
|
end
|
data/lib/toolchest/rack_app.rb
CHANGED
|
@@ -5,9 +5,8 @@ module Toolchest
|
|
|
5
5
|
def initialize(mount_key: :default)
|
|
6
6
|
@mount_key = mount_key.to_sym
|
|
7
7
|
@server = build_mcp_server
|
|
8
|
-
|
|
9
|
-
@
|
|
10
|
-
install_handlers!
|
|
8
|
+
# Transport auto-sets server.transport via super(server)
|
|
9
|
+
@transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server, **config.transport_options)
|
|
11
10
|
end
|
|
12
11
|
|
|
13
12
|
def call(env)
|
|
@@ -49,9 +48,15 @@ module Toolchest
|
|
|
49
48
|
def config = Toolchest.configuration(@mount_key)
|
|
50
49
|
|
|
51
50
|
def build_mcp_server
|
|
51
|
+
router = Toolchest.router(@mount_key)
|
|
52
|
+
|
|
52
53
|
opts = {
|
|
53
54
|
name: config.resolved_server_name,
|
|
54
55
|
version: config.server_version,
|
|
56
|
+
tools: build_mcp_tools(router),
|
|
57
|
+
prompts: build_mcp_prompts(router),
|
|
58
|
+
resources: build_mcp_resources(router),
|
|
59
|
+
resource_templates: build_mcp_resource_templates(router),
|
|
55
60
|
capabilities: {
|
|
56
61
|
tools: { listChanged: true },
|
|
57
62
|
prompts: { listChanged: true },
|
|
@@ -63,43 +68,139 @@ module Toolchest
|
|
|
63
68
|
|
|
64
69
|
opts[:description] = config.server_description if config.server_description
|
|
65
70
|
opts[:instructions] = config.server_instructions if config.server_instructions
|
|
71
|
+
opts[:page_size] = config.page_size if config.page_size
|
|
72
|
+
opts[:ttl_ms] = config.cache_ttl if config.cache_ttl
|
|
73
|
+
opts[:cache_scope] = config.cache_scope if config.cache_scope
|
|
66
74
|
|
|
67
|
-
MCP::Server.new(**opts)
|
|
75
|
+
server = MCP::Server.new(**opts)
|
|
76
|
+
router.mcp_server = server
|
|
77
|
+
install_custom_handlers!(server, router)
|
|
78
|
+
server
|
|
68
79
|
end
|
|
69
80
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
81
|
+
# Wire handlers that need custom dispatch logic:
|
|
82
|
+
# - tools/list: scope filtering based on current auth
|
|
83
|
+
# - resources/read: delegates to our router's resource lookup
|
|
84
|
+
# - completion/complete: delegates to our router's enum completion
|
|
85
|
+
def install_custom_handlers!(server, router)
|
|
86
|
+
# tools/list goes through the handler hash (else branch in dispatch),
|
|
87
|
+
# so overriding it gives us scope-filtered listing.
|
|
88
|
+
server.instance_variable_get(:@handlers)[MCP::Methods::TOOLS_LIST] = ->(params) {
|
|
89
|
+
{ tools: router.tools_for_handler }
|
|
90
|
+
}
|
|
75
91
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
+
# resources/read has a public setter — use it
|
|
93
|
+
server.resources_read_handler do |params|
|
|
94
|
+
router.resources_read_response(params)
|
|
92
95
|
end
|
|
93
96
|
|
|
94
|
-
# completion/complete
|
|
95
|
-
|
|
96
|
-
server.define_singleton_method(:complete) do |params|
|
|
97
|
+
# completion/complete has a public setter — use it
|
|
98
|
+
server.completion_handler do |params|
|
|
97
99
|
arg_name = params.dig(:argument, :name) || params.dig(:argument, "name")
|
|
98
100
|
values = arg_name ? router.completion_values(arg_name) : []
|
|
99
101
|
{ completion: { values: values, hasMore: false } }
|
|
100
102
|
end
|
|
101
103
|
end
|
|
102
104
|
|
|
105
|
+
# --- Tool bridge ---
|
|
106
|
+
# Build an MCP::Tool subclass for each Toolchest::ToolDefinition.
|
|
107
|
+
# The tool's .call sets up Current context and delegates to Router#dispatch.
|
|
108
|
+
|
|
109
|
+
def build_mcp_tools(router)
|
|
110
|
+
naming = config.tool_naming
|
|
111
|
+
router.toolbox_classes.flat_map { |klass|
|
|
112
|
+
klass.tool_definitions.values.map { |td|
|
|
113
|
+
build_mcp_tool(td, router, naming)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_mcp_tool(td, router, naming)
|
|
119
|
+
tn = td.tool_name(naming)
|
|
120
|
+
desc = td.description
|
|
121
|
+
ttl = td.title
|
|
122
|
+
schema = td.input_schema
|
|
123
|
+
out_schema = td.output_schema
|
|
124
|
+
hints = td.resolved_annotations
|
|
125
|
+
r = router
|
|
126
|
+
|
|
127
|
+
Class.new(MCP::Tool) do
|
|
128
|
+
tool_name tn
|
|
129
|
+
title ttl if ttl
|
|
130
|
+
description desc
|
|
131
|
+
input_schema schema
|
|
132
|
+
output_schema out_schema if out_schema
|
|
133
|
+
|
|
134
|
+
if hints.any?
|
|
135
|
+
annotations({
|
|
136
|
+
read_only_hint: hints[:readOnlyHint],
|
|
137
|
+
destructive_hint: hints[:destructiveHint],
|
|
138
|
+
idempotent_hint: hints[:idempotentHint],
|
|
139
|
+
open_world_hint: hints[:openWorldHint],
|
|
140
|
+
}.compact)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
define_singleton_method(:call) do |server_context: nil, **args|
|
|
144
|
+
Toolchest::Current.mcp_server_context = server_context
|
|
145
|
+
response = r.dispatch(tn, args)
|
|
146
|
+
MCP::Tool::Response.new(response[:content], error: response[:isError] || false)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# --- Prompt bridge ---
|
|
152
|
+
# Build an MCP::Prompt subclass for each prompt defined in toolboxes.
|
|
153
|
+
# The prompt's .template delegates to Router#prompts_get.
|
|
154
|
+
|
|
155
|
+
def build_mcp_prompts(router)
|
|
156
|
+
router.toolbox_classes.flat_map { |klass|
|
|
157
|
+
klass.prompts.map { |p| build_mcp_prompt(p, router) }
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def build_mcp_prompt(pdef, router)
|
|
162
|
+
pn = pdef[:name]
|
|
163
|
+
pt = pdef[:title]
|
|
164
|
+
desc = pdef[:description]
|
|
165
|
+
args_list = (pdef[:arguments] || {}).map { |name, opts|
|
|
166
|
+
MCP::Prompt::Argument.new(
|
|
167
|
+
name: name.to_s,
|
|
168
|
+
description: opts[:description],
|
|
169
|
+
required: opts[:required] || false
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
r = router
|
|
173
|
+
|
|
174
|
+
Class.new(MCP::Prompt) do
|
|
175
|
+
prompt_name pn
|
|
176
|
+
title pt if pt
|
|
177
|
+
description desc
|
|
178
|
+
arguments args_list
|
|
179
|
+
|
|
180
|
+
define_singleton_method(:template) do |args, server_context: nil|
|
|
181
|
+
Toolchest::Current.mcp_server_context = server_context
|
|
182
|
+
r.prompts_get(pn, args || {})
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# --- Resource bridge ---
|
|
188
|
+
# Create MCP::Resource instances for listing (reads go through resources_read_handler).
|
|
189
|
+
|
|
190
|
+
def build_mcp_resources(router)
|
|
191
|
+
router.toolbox_classes.flat_map(&:resources)
|
|
192
|
+
.reject { |r| r[:template] }
|
|
193
|
+
.map { |r| MCP::Resource.new(uri: r[:uri], name: r[:name], title: r[:title], description: r[:description]) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def build_mcp_resource_templates(router)
|
|
197
|
+
router.toolbox_classes.flat_map(&:resources)
|
|
198
|
+
.select { |r| r[:template] }
|
|
199
|
+
.map { |r| MCP::ResourceTemplate.new(uri_template: r[:uri], name: r[:name], title: r[:title], description: r[:description]) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# --- Auth ---
|
|
203
|
+
|
|
103
204
|
def authenticate(request)
|
|
104
205
|
strategy = case config.auth
|
|
105
206
|
when :none then Auth::None.new
|
data/lib/toolchest/router.rb
CHANGED
|
@@ -60,6 +60,13 @@ module Toolchest
|
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
payload = {
|
|
64
|
+
tool: tool_name,
|
|
65
|
+
toolbox: definition.toolbox_class.name,
|
|
66
|
+
action: definition.method_name,
|
|
67
|
+
arguments: arguments
|
|
68
|
+
}
|
|
69
|
+
|
|
63
70
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
64
71
|
auth = Toolchest::Current.auth
|
|
65
72
|
token_hint = extract_token_hint(auth)
|
|
@@ -75,6 +82,10 @@ module Toolchest
|
|
|
75
82
|
duration = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(1)
|
|
76
83
|
log_request_complete(definition, response, duration)
|
|
77
84
|
|
|
85
|
+
payload[:duration] = duration
|
|
86
|
+
payload[:error] = response[:isError] || false
|
|
87
|
+
ActiveSupport::Notifications.instrument("dispatch.toolchest", payload)
|
|
88
|
+
|
|
78
89
|
response
|
|
79
90
|
end
|
|
80
91
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Toolchest
|
|
2
2
|
class ToolDefinition
|
|
3
|
-
attr_reader :method_name, :description, :params, :toolbox_class, :custom_name, :access_level, :scope, :annotations
|
|
3
|
+
attr_reader :method_name, :description, :params, :toolbox_class, :custom_name, :access_level, :scope, :annotations, :title, :output_schema
|
|
4
4
|
|
|
5
|
-
def initialize(method_name:, description:, params:, toolbox_class:, custom_name: nil, access_level: nil, scope: nil, annotations: nil)
|
|
5
|
+
def initialize(method_name:, description:, params:, toolbox_class:, custom_name: nil, access_level: nil, scope: nil, annotations: nil, title: nil, output_schema: nil)
|
|
6
6
|
@method_name = method_name.to_sym
|
|
7
7
|
@description = description
|
|
8
8
|
@params = params
|
|
@@ -11,6 +11,8 @@ module Toolchest
|
|
|
11
11
|
@access_level = access_level
|
|
12
12
|
@scope = scope ? Array(scope) : nil
|
|
13
13
|
@annotations = annotations
|
|
14
|
+
@title = title
|
|
15
|
+
@output_schema = output_schema
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def tool_name(naming_strategy = nil)
|
|
@@ -25,6 +27,8 @@ module Toolchest
|
|
|
25
27
|
description: @description,
|
|
26
28
|
inputSchema: input_schema
|
|
27
29
|
}
|
|
30
|
+
schema[:title] = @title if @title
|
|
31
|
+
schema[:outputSchema] = @output_schema if @output_schema
|
|
28
32
|
hints = resolved_annotations
|
|
29
33
|
schema[:annotations] = hints if hints.any?
|
|
30
34
|
schema
|
data/lib/toolchest/toolbox.rb
CHANGED
|
@@ -48,10 +48,10 @@ module Toolchest
|
|
|
48
48
|
.flat_map { |a| a.send(:own_prompts) }
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
-
def tool(description, name: nil, access: nil, scope: nil, annotations: nil, &block)
|
|
51
|
+
def tool(description, name: nil, title: nil, access: nil, scope: nil, annotations: nil, output: nil, &block)
|
|
52
52
|
builder = ToolBuilder.new
|
|
53
53
|
builder.instance_eval(&block) if block
|
|
54
|
-
@_pending_tool = { description:, custom_name: name, access_level: access, scope:, annotations:, builder: }
|
|
54
|
+
@_pending_tool = { description:, custom_name: name, title:, access_level: access, scope:, annotations:, output_schema: output, builder: }
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def default_param(name, type, description = "", **options)
|
|
@@ -67,11 +67,12 @@ module Toolchest
|
|
|
67
67
|
}
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
def resource(uri, name: nil, description: nil, &block)
|
|
70
|
+
def resource(uri, name: nil, title: nil, description: nil, &block)
|
|
71
71
|
template = uri.include?("{")
|
|
72
72
|
@_resources << {
|
|
73
73
|
uri: uri,
|
|
74
74
|
name: name || uri,
|
|
75
|
+
title: title,
|
|
75
76
|
description: description,
|
|
76
77
|
block: block,
|
|
77
78
|
template: template,
|
|
@@ -79,9 +80,10 @@ module Toolchest
|
|
|
79
80
|
}
|
|
80
81
|
end
|
|
81
82
|
|
|
82
|
-
def prompt(prompt_name, description: nil, arguments: {}, &block)
|
|
83
|
+
def prompt(prompt_name, title: nil, description: nil, arguments: {}, &block)
|
|
83
84
|
@_prompts << {
|
|
84
85
|
name: prompt_name,
|
|
86
|
+
title: title,
|
|
85
87
|
description: description,
|
|
86
88
|
arguments: arguments,
|
|
87
89
|
block: block,
|
|
@@ -111,9 +113,11 @@ module Toolchest
|
|
|
111
113
|
params: params,
|
|
112
114
|
toolbox_class: self,
|
|
113
115
|
custom_name: pending[:custom_name],
|
|
116
|
+
title: pending[:title],
|
|
114
117
|
access_level: pending[:access_level],
|
|
115
118
|
scope: pending[:scope],
|
|
116
|
-
annotations: pending[:annotations]
|
|
119
|
+
annotations: pending[:annotations],
|
|
120
|
+
output_schema: pending[:output_schema]
|
|
117
121
|
)
|
|
118
122
|
|
|
119
123
|
@_tool_definitions[method_name.to_sym] = definition
|
|
@@ -229,17 +233,29 @@ module Toolchest
|
|
|
229
233
|
throw :halt
|
|
230
234
|
end
|
|
231
235
|
|
|
232
|
-
def mcp_log(level, message)
|
|
236
|
+
def mcp_log(level, message)
|
|
237
|
+
ctx = Toolchest::Current.mcp_server_context
|
|
238
|
+
if ctx
|
|
239
|
+
ctx.notify_log_message(data: message, level: level.to_s, logger: "Toolchest")
|
|
240
|
+
else
|
|
241
|
+
Toolchest.router(Toolchest::Current.mount_key&.to_sym || :default).notify_log(level: level.to_s, message: message)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
233
244
|
|
|
234
245
|
# Report progress during long-running actions.
|
|
235
246
|
# Client shows a progress bar. total and message are optional.
|
|
236
247
|
def mcp_progress(progress, total: nil, message: nil)
|
|
248
|
+
ctx = Toolchest::Current.mcp_server_context
|
|
249
|
+
if ctx
|
|
250
|
+
ctx.report_progress(progress, total: total, message: message)
|
|
251
|
+
return
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Legacy fallback
|
|
237
255
|
session = Toolchest::Current.mcp_session
|
|
238
256
|
return unless session
|
|
239
|
-
|
|
240
257
|
token = Toolchest::Current.mcp_progress_token
|
|
241
258
|
return unless token
|
|
242
|
-
|
|
243
259
|
session.notify_progress(
|
|
244
260
|
progress_token: token,
|
|
245
261
|
progress: progress,
|
|
@@ -260,7 +276,8 @@ module Toolchest
|
|
|
260
276
|
# s.temperature 0.3
|
|
261
277
|
# end
|
|
262
278
|
def mcp_sample(prompt = nil, context: nil, max_tokens: 1024, **kwargs, &block)
|
|
263
|
-
|
|
279
|
+
ctx = Toolchest::Current.mcp_server_context
|
|
280
|
+
session = ctx || Toolchest::Current.mcp_session
|
|
264
281
|
raise Toolchest::Error, "Sampling requires an MCP client that supports it" unless session
|
|
265
282
|
|
|
266
283
|
if block
|
|
@@ -282,11 +299,10 @@ module Toolchest
|
|
|
282
299
|
end
|
|
283
300
|
|
|
284
301
|
begin
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
)
|
|
302
|
+
call_opts = { messages: messages, **options }
|
|
303
|
+
# Legacy path: explicitly pass related_request_id (ServerContext handles this automatically)
|
|
304
|
+
call_opts[:related_request_id] = Toolchest::Current.mcp_request_id unless ctx
|
|
305
|
+
result = session.create_sampling_message(**call_opts)
|
|
290
306
|
rescue RuntimeError => e
|
|
291
307
|
raise Toolchest::Error, "Sampling failed: #{e.message}"
|
|
292
308
|
end
|
|
@@ -301,6 +317,43 @@ module Toolchest
|
|
|
301
317
|
end
|
|
302
318
|
end
|
|
303
319
|
|
|
320
|
+
# Ask the client's user for input via a form.
|
|
321
|
+
# Returns the elicitation result hash from the client.
|
|
322
|
+
#
|
|
323
|
+
# result = mcp_elicit("Please confirm the shipping address",
|
|
324
|
+
# schema: {
|
|
325
|
+
# type: "object",
|
|
326
|
+
# properties: {
|
|
327
|
+
# confirmed: { type: "boolean", description: "Address is correct?" }
|
|
328
|
+
# },
|
|
329
|
+
# required: ["confirmed"]
|
|
330
|
+
# })
|
|
331
|
+
# halt error: "Cancelled" unless result["action"] == "accept"
|
|
332
|
+
#
|
|
333
|
+
def mcp_elicit(message, schema:)
|
|
334
|
+
ctx = Toolchest::Current.mcp_server_context
|
|
335
|
+
raise Toolchest::Error, "Elicitation requires an MCP client that supports it" unless ctx
|
|
336
|
+
|
|
337
|
+
begin
|
|
338
|
+
ctx.create_form_elicitation(message: message, requested_schema: schema)
|
|
339
|
+
rescue RuntimeError => e
|
|
340
|
+
raise Toolchest::Error, "Elicitation failed: #{e.message}"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Check if the current request has been cancelled by the client.
|
|
345
|
+
def mcp_cancelled?
|
|
346
|
+
ctx = Toolchest::Current.mcp_server_context
|
|
347
|
+
ctx&.cancelled? || false
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Raise MCP::CancelledError if the current request has been cancelled.
|
|
351
|
+
# Use in long-running loops to bail early.
|
|
352
|
+
def mcp_raise_if_cancelled!
|
|
353
|
+
ctx = Toolchest::Current.mcp_server_context
|
|
354
|
+
ctx&.raise_if_cancelled!
|
|
355
|
+
end
|
|
356
|
+
|
|
304
357
|
def dispatch(action_name)
|
|
305
358
|
@_action_name = action_name
|
|
306
359
|
|
data/lib/toolchest/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: toolchest
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nora
|
|
@@ -29,14 +29,14 @@ dependencies:
|
|
|
29
29
|
requirements:
|
|
30
30
|
- - ">="
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: '0
|
|
32
|
+
version: '1.0'
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: '0
|
|
39
|
+
version: '1.0'
|
|
40
40
|
description: A Rails engine that maps the Model Context Protocol (MCP) to Rails conventions.
|
|
41
41
|
If you've built a controller, you already know how to build a toolbox.
|
|
42
42
|
executables: []
|