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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c73baf6206cfd9d1f744867f2b3e35e6094a2f4304dec858ce0350c1d54c03ba
4
- data.tar.gz: 82c480aa62bdcb7c19df80a1aebeb4a7cf4dbb4ec8a199b25fb4a8abe6da69a1
3
+ metadata.gz: 80dd8a25c340a7918653dc8e63fa0d899a23e10306c87d3143456688891de9d7
4
+ data.tar.gz: 0f2416b46244ff3e03aa28636e81d126e18303f8385bea0053b4e90c04c44551
5
5
  SHA512:
6
- metadata.gz: 82bcf0c2b9ee5c5d0e9365641dcd7e2e6119be86e66dadee37b1bfaa44582511b2c3e7a5f2cbf31b0164dd31d4c3823090900bb4dd9bf18ea9f215babeaa2e47
7
- data.tar.gz: f0e79e3d5cd3ad4bd09fe68975e129a3528f78f5f5ec7eaa822bf37c85631a295df2d0e10acbaf483ee62fa243ce563bd1a264688e3f3b90e1884a96d5173e1c
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
- unless key
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
- if params[:client_id].present? && app.uid != params[:client_id]
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
- if !grant.uses_pkce? && !app.confidential?
41
- return error_response("invalid_request", "PKCE required for public clients")
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.revoke!
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) = active.find_by(token_digest: Digest::SHA256.hexdigest(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?)
@@ -19,7 +19,7 @@ module Toolchest
19
19
  end
20
20
  end
21
21
 
22
- def create_migration
22
+ def copy_migration
23
23
  case strategy
24
24
  when "token"
25
25
  migration_template "create_toolchest_tokens.rb.tt",
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
 
@@ -7,7 +7,7 @@ module Toolchest
7
7
 
8
8
  def extract_bearer_token(request)
9
9
  auth_header = request.env["HTTP_AUTHORIZATION"] || ""
10
- match = auth_header.match(/\ABearer\s+(.+)\z/i)
10
+ match = auth_header.match(/\ABearer\s+(\S+)\z/i)
11
11
  match&.[](1)
12
12
  end
13
13
  end
@@ -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
@@ -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
@@ -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
- @transport = MCP::Server::Transports::StreamableHTTPTransport.new(@server)
9
- @server.transport = @transport
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
- def install_handlers!
71
- router = Toolchest.router(@mount_key)
72
- server = @server
73
-
74
- router.mcp_server = server
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
- 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
+ # 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 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
+ # 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
@@ -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
@@ -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) = Toolchest.router(Toolchest::Current.mount_key&.to_sym || :default).notify_log(level: level.to_s, message: 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
- session = Toolchest::Current.mcp_session
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
- result = session.create_sampling_message(
286
- messages: messages,
287
- related_request_id: Toolchest::Current.mcp_request_id,
288
- **options
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
 
@@ -1,3 +1,3 @@
1
1
  module Toolchest
2
- VERSION = "0.3.6"
2
+ VERSION = "0.4.0"
3
3
  end
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.3.6
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.10'
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.10'
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: []