fast-mcp 1.4.0 → 1.6.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.
data/lib/mcp/tool.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'dry-schema'
4
4
 
5
+ Dry::Schema.load_extensions(:json_schema)
6
+
5
7
  # Extend Dry::Schema macros to support description
6
8
  module Dry
7
9
  module Schema
@@ -62,732 +64,316 @@ module Dry
62
64
  def description(text)
63
65
  key_name = name.to_sym
64
66
  schema_dsl.meta(key_name, :description, text)
67
+
68
+ # Mark this hash as having metadata so we know to track nested context
69
+ @has_metadata = true
65
70
  self
66
71
  end
67
- end
68
- end
69
- end
70
- end
71
72
 
72
- # Extend Dry::Schema DSL to store metadata
73
- module Dry
74
- module Schema
75
- class DSL
76
- def meta(key_name, meta_key, value)
77
- @meta ||= {}
78
- @meta[key_name] ||= {}
79
- @meta[key_name][meta_key] = value
80
- end
73
+ def hidden(hidden = true) # rubocop:disable Style/OptionalBooleanParameter
74
+ key_name = name.to_sym
75
+ schema_dsl.meta(key_name, :hidden, hidden)
81
76
 
82
- def meta_data
83
- @meta || {}
77
+ # Mark this hash as having metadata so we know to track nested context
78
+ @has_metadata = true
79
+ self
80
+ end
81
+
82
+ # Override call method to manage nested context
83
+ alias original_call call
84
+
85
+ def call(&block)
86
+ if block
87
+ # Use current context to track nested context if available
88
+ context = MetadataContext.current
89
+ if context
90
+ context.with_nested(name) do
91
+ original_call(&block)
92
+ end
93
+ else
94
+ original_call(&block)
95
+ end
96
+ else
97
+ original_call(&block)
98
+ end
99
+ end
84
100
  end
85
101
  end
86
102
  end
87
103
  end
88
104
 
89
- module FastMcp
90
- # Main Tool class that represents an MCP Tool
91
- class Tool
92
- class InvalidArgumentsError < StandardError; end
93
-
94
- class << self
95
- attr_accessor :server
96
-
97
- def arguments(&block)
98
- @input_schema = Dry::Schema.JSON(&block)
99
- end
100
-
101
- def input_schema
102
- @input_schema ||= Dry::Schema.JSON
103
- end
104
-
105
- def tool_name(name = nil)
106
- return @name || self.name if name.nil?
107
-
108
- @name = name
109
- end
110
-
111
- def description(description = nil)
112
- return @description if description.nil?
113
-
114
- @description = description
115
- end
116
-
117
- def call(**args)
118
- raise NotImplementedError, 'Subclasses must implement the call method'
119
- end
120
-
121
- def input_schema_to_json
122
- return nil unless @input_schema
123
-
124
- compiler = SchemaCompiler.new
125
- compiler.process(@input_schema)
126
- end
127
- end
128
-
129
- def initialize
130
- @_meta = {}
131
- end
132
-
133
- attr_accessor :_meta
134
-
135
- def notify_resource_updated(uri)
136
- self.class.server.notify_resource_updated(uri)
137
- end
138
-
139
- def call_with_schema_validation!(**args)
140
- arg_validation = self.class.input_schema.call(args)
141
- raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any?
142
-
143
- # When calling the tool, its metadata can be altered to be returned in response.
144
- # We return the altered metadata with the tool's result
145
- [call(**args), _meta]
146
- end
105
+ # Context object for managing nested metadata collection
106
+ class MetadataContext
107
+ def initialize
108
+ @metadata = {}
109
+ @nesting_stack = []
147
110
  end
148
111
 
149
- # Module for handling schema metadata
150
- module SchemaMetadataExtractor
151
- # Extract metadata from a schema
152
- def extract_metadata_from_schema(schema)
153
- # a deeply-assignable hash, the default value of a key is {}
154
- metadata = Hash.new { |h, k| h[k] = Hash.new(&h.default_proc) }
155
-
156
- # Extract descriptions from the top-level schema
157
- if schema.respond_to?(:schema_dsl) && schema.schema_dsl.respond_to?(:meta_data)
158
- schema.schema_dsl.meta_data.each do |key, meta|
159
- metadata[key.to_s][:description] = meta[:description] if meta[:description]
160
- metadata[key.to_s][:hidden] = meta[:hidden]
161
- end
162
- end
112
+ attr_reader :metadata
163
113
 
164
- # Extract metadata from nested schemas using AST
165
- schema.rules.each_value do |rule|
166
- next unless rule.respond_to?(:ast)
167
-
168
- extract_metadata_from_ast(rule.ast, metadata)
169
- end
114
+ def store(property_name, meta_key, value)
115
+ path = current_path + [property_name.to_s]
116
+ full_path = path.join('.')
170
117
 
171
- metadata
172
- end
118
+ @metadata[full_path] ||= {}
119
+ @metadata[full_path][meta_key] = value
120
+ end
173
121
 
174
- # Extract metadata from AST
175
- def extract_metadata_from_ast(ast, metadata, parent_key = nil)
176
- return unless ast.is_a?(Array)
122
+ def with_nested(parent_property)
123
+ @nesting_stack.push(parent_property.to_s)
124
+ yield
125
+ ensure
126
+ @nesting_stack.pop
127
+ end
177
128
 
178
- process_key_node(ast, metadata, parent_key) if ast[0] == :key
179
- process_set_node(ast, metadata, parent_key) if ast[0] == :set
180
- process_and_node(ast, metadata, parent_key) if ast[0] == :and
181
- end
129
+ def current_path
130
+ @nesting_stack.dup
131
+ end
182
132
 
183
- # Process a key node in the AST
184
- def process_key_node(ast, metadata, parent_key)
185
- return unless ast[1].is_a?(Array) && ast[1].size >= 2
133
+ # Class method to set/get current context for thread-safe access
134
+ def self.current
135
+ Thread.current[:metadata_context]
136
+ end
186
137
 
187
- key = ast[1][0]
188
- full_key = parent_key ? "#{parent_key}.#{key}" : key.to_s
138
+ def self.with_context(context)
139
+ old_context = Thread.current[:metadata_context]
140
+ Thread.current[:metadata_context] = context
141
+ yield
142
+ ensure
143
+ Thread.current[:metadata_context] = old_context
144
+ end
145
+ end
189
146
 
190
- # Process nested AST
191
- extract_metadata_from_ast(ast[1][1], metadata, full_key) if ast[1][1].is_a?(Array)
192
- end
147
+ # Extend Dry::Schema DSL to store metadata
148
+ module Dry
149
+ module Schema
150
+ class DSL
151
+ def meta(key_name, meta_key, value)
152
+ @meta ||= {}
153
+ @meta[key_name] ||= {}
154
+ @meta[key_name][meta_key] = value
193
155
 
194
- # Process a set node in the AST
195
- def process_set_node(ast, metadata, parent_key)
196
- return unless ast[1].is_a?(Array)
156
+ # Store in current context if available
157
+ context = MetadataContext.current
158
+ return unless context
197
159
 
198
- ast[1].each do |set_node|
199
- extract_metadata_from_ast(set_node, metadata, parent_key)
160
+ context.store(key_name, meta_key, value)
200
161
  end
201
- end
202
-
203
- # Process an and node in the AST
204
- def process_and_node(ast, metadata, parent_key)
205
- return unless ast[1].is_a?(Array)
206
162
 
207
- # Process each child node
208
- ast[1].each do |and_node|
209
- extract_metadata_from_ast(and_node, metadata, parent_key)
163
+ def meta_data
164
+ @meta || {}
210
165
  end
211
-
212
- # Process nested properties
213
- process_nested_properties(ast, metadata, parent_key)
214
166
  end
167
+ end
168
+ end
215
169
 
216
- # Process nested properties in an and node
217
- def process_nested_properties(ast, metadata, parent_key)
218
- ast[1].each do |node|
219
- next unless node[0] == :key && node[1].is_a?(Array) && node[1][1].is_a?(Array) && node[1][1][0] == :and
220
-
221
- key_name = node[1][0]
222
- nested_key = parent_key ? "#{parent_key}.#{key_name}" : key_name.to_s
223
-
224
- process_nested_schema_ast(node[1][1], metadata, nested_key)
225
- end
226
- end
170
+ # Schema metadata processor for handling custom predicates in JSON schema output
171
+ class SchemaMetadataProcessor
172
+ def self.process(schema, collected_metadata = {})
173
+ return nil unless schema
227
174
 
228
- # Process a nested schema
229
- def process_nested_schema_ast(ast, metadata, nested_key)
230
- return unless ast[1].is_a?(Array)
175
+ base_schema = schema.json_schema.tap { _1.delete(:$schema) }
176
+ metadata = extract_metadata(schema)
231
177
 
232
- ast[1].each do |subnode|
233
- next unless subnode[0] == :set && subnode[1].is_a?(Array)
178
+ # Merge traditional metadata with collected nested metadata
179
+ all_metadata = merge_metadata(metadata, collected_metadata)
234
180
 
235
- subnode[1].each do |set_node|
236
- next unless set_node[0] == :and && set_node[1].is_a?(Array)
181
+ apply_metadata_to_schema(base_schema, all_metadata)
182
+ end
237
183
 
238
- process_nested_keys(set_node, metadata, nested_key)
239
- end
240
- end
241
- end
184
+ private_class_method def self.extract_metadata(schema)
185
+ schema_dsl = schema.instance_variable_get(:@schema_dsl)
186
+ schema_dsl&.meta_data || {}
187
+ end
242
188
 
243
- # Process nested keys in a schema
244
- def process_nested_keys(set_node, metadata, nested_key)
245
- set_node[1].each do |and_node|
246
- next unless and_node[0] == :key && and_node[1].is_a?(Array) && and_node[1].size >= 2
189
+ private_class_method def self.merge_metadata(traditional, collected)
190
+ # Remove internal keys from collected metadata
191
+ filtered_collected = collected.reject { |key, _| key.to_s.start_with?('_') }
247
192
 
248
- nested_field = and_node[1][0]
249
- nested_path = "#{nested_key}.#{nested_field}"
193
+ # Start with traditional metadata
194
+ merged = traditional.dup
250
195
 
251
- extract_metadata(and_node, metadata, nested_path)
252
- end
196
+ # Add collected metadata with full paths
197
+ filtered_collected.each do |path, metadata|
198
+ merged[path] = metadata
253
199
  end
254
200
 
255
- # Extract metadata from a node
256
- def extract_metadata(and_node, metadata, nested_path)
257
- return unless and_node[1][1].is_a?(Array) && and_node[1][1][1].is_a?(Array)
201
+ merged
202
+ end
258
203
 
259
- and_node[1][1][1].each do |meta_node|
260
- next unless meta_node[0] == :meta && meta_node[1].is_a?(Hash) && meta_node[1][:description]
204
+ private_class_method def self.apply_metadata_to_schema(base_schema, metadata)
205
+ return base_schema if !base_schema[:properties] || metadata.empty?
261
206
 
262
- metadata[nested_path] = meta_node[1][:description]
263
- end
264
- end
207
+ base_schema[:properties] = process_properties_recursively(base_schema[:properties], metadata, [])
208
+ base_schema[:required] = filter_required_properties(base_schema[:required], base_schema[:properties])
209
+ base_schema
265
210
  end
266
211
 
267
- # Module for handling rule type detection
268
- module RuleTypeDetector
269
- # Check if a rule is for a hash type
270
- def hash_type?(rule)
271
- return true if direct_hash_predicate?(rule) || nested_hash_predicate?(rule)
212
+ private_class_method def self.process_properties_recursively(properties, metadata, path_prefix = [])
213
+ filtered_properties = {}
272
214
 
273
- false
274
- end
215
+ properties.each do |property_name, property_schema|
216
+ current_path = (path_prefix + [property_name.to_s]).join('.')
275
217
 
276
- # Check for direct hash predicate
277
- def direct_hash_predicate?(rule)
278
- return false unless rule.is_a?(Dry::Logic::Operations::And)
218
+ # Look for metadata using both simple key and full path
219
+ property_key = property_name.to_sym
220
+ property_metadata = metadata[property_key] || metadata[current_path]
279
221
 
280
- rule.rules.any? { |r| r.respond_to?(:name) && r.name == :hash? }
281
- end
222
+ # Skip hidden properties entirely
223
+ next if property_metadata&.dig(:hidden)
282
224
 
283
- # Check for nested hash predicate
284
- def nested_hash_predicate?(rule)
285
- if rule.is_a?(Dry::Logic::Operations::Key) && rule.rule.is_a?(Dry::Logic::Operations::And)
286
- return rule.rule.rules.any? { |r| r.respond_to?(:name) && r.name == :hash? }
287
- end
225
+ # Add description if present
226
+ property_schema[:description] = property_metadata[:description] if property_metadata&.dig(:description)
288
227
 
289
- if rule.respond_to?(:right) && rule.right.is_a?(Dry::Logic::Operations::Key) &&
290
- rule.right.rule.is_a?(Dry::Logic::Operations::And)
291
- return rule.right.rule.rules.any? { |r| r.respond_to?(:name) && r.name == :hash? }
228
+ # Recursively process nested object properties
229
+ if property_schema[:type] == 'object' && property_schema[:properties]
230
+ nested_path = path_prefix + [property_name.to_s]
231
+ property_schema[:properties] =
232
+ process_properties_recursively(property_schema[:properties], metadata, nested_path)
233
+ property_schema[:required] =
234
+ filter_required_properties(property_schema[:required], property_schema[:properties])
235
+ # Recursively process array items with object properties
236
+ elsif property_schema[:type] == 'array' && property_schema.dig(:items, :type) == 'object' &&
237
+ property_schema.dig(:items, :properties)
238
+ nested_path = path_prefix + [property_name.to_s]
239
+ property_schema[:items][:properties] =
240
+ process_properties_recursively(property_schema[:items][:properties], metadata, nested_path)
241
+ property_schema[:items][:required] =
242
+ filter_required_properties(property_schema[:items][:required], property_schema[:items][:properties])
292
243
  end
293
244
 
294
- false
245
+ filtered_properties[property_name] = property_schema
295
246
  end
296
247
 
297
- # Check if a rule is for an array type
298
- def array_type?(rule)
299
- rule.is_a?(Dry::Logic::Operations::And) &&
300
- rule.rules.any? { |r| r.respond_to?(:name) && r.name == :array? }
301
- end
248
+ filtered_properties
302
249
  end
303
250
 
304
- # Module for handling predicates
305
- module PredicateHandler
306
- # Extract predicates from a rule
307
- def extract_predicates(rule, key, properties = nil)
308
- properties ||= @json_schema[:properties]
309
-
310
- case rule
311
- when Dry::Logic::Operations::And
312
- rule.rules.each { |r| extract_predicates(r, key, properties) }
313
- when Dry::Logic::Operations::Implication
314
- extract_predicates(rule.right, key, properties)
315
- when Dry::Logic::Operations::Key
316
- extract_predicates(rule.rule, key, properties)
317
- when Dry::Logic::Operations::Set
318
- rule.rules.each { |r| extract_predicates(r, key, properties) }
319
- else
320
- process_predicate(rule, key, properties) if rule.respond_to?(:name)
321
- end
322
- end
323
-
324
- # Process a predicate
325
- def process_predicate(rule, key, properties)
326
- predicate_name = rule.name
327
- args = extract_predicate_args(rule)
328
- add_predicate_description(predicate_name, args, key, properties)
329
- end
251
+ private_class_method def self.filter_required_properties(required_array, properties)
252
+ return [] unless required_array
330
253
 
331
- # Extract arguments from a predicate
332
- def extract_predicate_args(rule)
333
- if rule.respond_to?(:args) && !rule.args.nil?
334
- rule.args
335
- elsif rule.respond_to?(:predicate) && rule.predicate.respond_to?(:arguments)
336
- rule.predicate.arguments
337
- else
338
- []
339
- end
340
- end
341
-
342
- # Add predicate description to schema
343
- def add_predicate_description(predicate_name, args, key_name, properties)
344
- property = properties[key_name]
345
-
346
- case predicate_name
347
- when :array?, :bool?, :decimal?, :float?, :hash?, :int?, :nil?, :str?
348
- add_basic_type(predicate_name, property)
349
- when :date?, :date_time?, :time?
350
- add_date_time_format(predicate_name, property)
351
- when :min_size?, :max_size?, :included_in?
352
- add_string_constraint(predicate_name, args, property)
353
- when :filled?
354
- # Already handled by the required array
355
- when :uri?
356
- property[:format] = 'uri'
357
- when :uuid_v1?, :uuid_v2?, :uuid_v3?, :uuid_v4?, :uuid_v5?
358
- add_uuid_pattern(predicate_name, property)
359
- when :gt?, :gteq?, :lt?, :lteq?
360
- add_numeric_constraint(predicate_name, args, property)
361
- when :odd?, :even?
362
- add_number_constraint(predicate_name, property)
363
- when :format?
364
- add_format_constraint(args, property)
365
- when :key?
366
- nil
367
- end
254
+ required_array.select do |required_prop|
255
+ properties.key?(required_prop.to_sym) || properties.key?(required_prop.to_s)
368
256
  end
369
257
  end
258
+ end
370
259
 
371
- # Module for handling basic type predicates
372
- module BasicTypePredicateHandler
373
- # Add basic type to schema
374
- def add_basic_type(predicate_name, property)
375
- case predicate_name
376
- when :array?
377
- property[:type] = 'array'
378
- property[:items] = {}
379
- when :bool?
380
- property[:type] = 'boolean'
381
- when :int?, :decimal?, :float?
382
- property[:type] = 'number'
383
- when :hash?
384
- property[:type] = 'object'
385
- when :nil?
386
- property[:type] = 'null'
387
- when :str?
388
- property[:type] = 'string'
389
- end
390
- end
260
+ module FastMcp
261
+ # Main Tool class that represents an MCP Tool
262
+ class Tool
263
+ class InvalidArgumentsError < StandardError; end
391
264
 
392
- # Add string constraint to schema
393
- def add_string_constraint(predicate_name, args, property)
394
- case predicate_name
395
- when :min_size?
396
- property[:minLength] = args[0].to_i
397
- when :max_size?
398
- property[:maxLength] = args[0].to_i
399
- when :included_in?
400
- property[:enum] = args[0].to_a
401
- end
402
- end
265
+ class << self
266
+ attr_accessor :server
403
267
 
404
- # Add numeric constraint to schema
405
- def add_numeric_constraint(predicate_name, args, property)
406
- case predicate_name
407
- when :gt?
408
- property[:exclusiveMinimum] = args[0]
409
- when :gteq?
410
- property[:minimum] = args[0]
411
- when :lt?
412
- property[:exclusiveMaximum] = args[0]
413
- when :lteq?
414
- property[:maximum] = args[0]
268
+ # Add tagging support for tools
269
+ def tags(*tag_list)
270
+ if tag_list.empty?
271
+ @tags || []
272
+ else
273
+ @tags = tag_list.flatten.map(&:to_sym)
274
+ end
415
275
  end
416
- end
417
- end
418
276
 
419
- # Module for handling format predicates
420
- module FormatPredicateHandler
421
- # Add date/time format to schema
422
- def add_date_time_format(predicate_name, property)
423
- property[:type] = 'string'
424
- case predicate_name
425
- when :date?
426
- property[:format] = 'date'
427
- when :date_time?
428
- property[:format] = 'date-time'
429
- when :time?
430
- property[:format] = 'time'
277
+ # Add metadata support for tools
278
+ def metadata(key = nil, value = nil)
279
+ @metadata ||= {}
280
+ if key.nil?
281
+ @metadata
282
+ elsif value.nil?
283
+ @metadata[key]
284
+ else
285
+ @metadata[key] = value
286
+ end
431
287
  end
432
- end
433
288
 
434
- # Add UUID pattern to schema
435
- def add_uuid_pattern(predicate_name, property)
436
- version = predicate_name.to_s.split('_').last[1].to_i
437
- property[:pattern] = if version == 4
438
- '^[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}$'
439
- else
440
- "^[0-9A-F]{8}-[0-9A-F]{4}-#{version}[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$"
441
- end
442
- end
289
+ def arguments(&block)
290
+ @metadata_context = MetadataContext.new
443
291
 
444
- # Add number constraint to schema
445
- def add_number_constraint(predicate_name, property)
446
- property[:type] = 'integer'
447
- case predicate_name
448
- when :odd?
449
- property[:not] = { multipleOf: 2 }
450
- when :even?
451
- property[:multipleOf] = 2
452
- end
453
- end
292
+ @input_schema = MetadataContext.with_context(@metadata_context) do
293
+ Dry::Schema.JSON(&block)
294
+ end
454
295
 
455
- # Add format constraint to schema
456
- def add_format_constraint(args, property)
457
- return unless args[0].is_a?(Symbol)
458
-
459
- case args[0]
460
- when :date_time
461
- property[:format] = 'date-time'
462
- when :date
463
- property[:format] = 'date'
464
- when :time
465
- property[:format] = 'time'
466
- when :email
467
- property[:format] = 'email'
468
- when :uri
469
- property[:format] = 'uri'
296
+ @collected_metadata = @metadata_context.metadata
470
297
  end
471
- end
472
- end
473
298
 
474
- # Module for handling nested rules
475
- module NestedRuleHandler
476
- # Extract nested rules from a rule
477
- def extract_nested_rules(rule)
478
- nested_rules = {}
479
-
480
- case rule
481
- when Dry::Logic::Operations::And
482
- extract_nested_rules_from_and(rule, nested_rules)
483
- when Dry::Logic::Operations::Implication
484
- extract_nested_rules_from_implication(rule, nested_rules)
485
- when Dry::Logic::Operations::Key
486
- extract_nested_rules_from_and(rule.rule, nested_rules) if rule.rule.is_a?(Dry::Logic::Operations::And)
299
+ def input_schema
300
+ @input_schema ||= Dry::Schema.JSON
487
301
  end
488
302
 
489
- nested_rules
490
- end
303
+ def tool_name(name = nil)
304
+ name = @name || self.name if name.nil?
305
+ return if name.nil?
491
306
 
492
- # Extract nested rules from an And operation
493
- def extract_nested_rules_from_and(rule, nested_rules)
494
- # Look for Set operations directly in the rule
495
- set_op = rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
307
+ name = name.gsub(/[^a-zA-Z0-9_-]/, '')[0, 64]
496
308
 
497
- if set_op
498
- process_set_operation(set_op, nested_rules)
499
- return
309
+ @name = name
500
310
  end
501
311
 
502
- # If no direct Set operation, look for Key operations in the rule structure
503
- key_ops = rule.rules.select { |r| r.is_a?(Dry::Logic::Operations::Key) }
504
-
505
- key_ops.each do |key_op|
506
- next unless key_op.rule.is_a?(Dry::Logic::Operations::And)
507
-
508
- # Look for Set operations in the Key operation's rule
509
- set_op = key_op.rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
510
- process_set_operation(set_op, nested_rules) if set_op
511
-
512
- # Also look for direct predicates
513
- key_op.rule.rules.each do |r|
514
- if r.respond_to?(:name) && r.name != :hash?
515
- nested_key = key_op.path
516
- nested_rules[nested_key] = key_op.rule
517
- end
518
- end
519
- end
520
- end
312
+ def description(description = nil)
313
+ return @description if description.nil?
521
314
 
522
- # Extract nested rules from an Implication operation
523
- def extract_nested_rules_from_implication(rule, nested_rules)
524
- # For optional fields (Implication), we need to check the right side
525
- if rule.right.is_a?(Dry::Logic::Operations::Key)
526
- extract_from_implication_key(rule.right, nested_rules)
527
- elsif rule.right.is_a?(Dry::Logic::Operations::And)
528
- extract_from_implication_and(rule.right, nested_rules)
315
+ @description = description
529
316
  end
530
- end
531
-
532
- # Extract from implication key
533
- def extract_from_implication_key(key_rule, nested_rules)
534
- return unless key_rule.rule.is_a?(Dry::Logic::Operations::And)
535
317
 
536
- # Look for Set operations directly in the rule
537
- set_op = key_rule.rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
538
- process_set_operation(set_op, nested_rules) if set_op
539
- end
540
-
541
- # Extract from implication and
542
- def extract_from_implication_and(and_rule, nested_rules)
543
- # Look for Set operations directly in the right side
544
- set_op = and_rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
318
+ def annotations(annotations_hash = nil)
319
+ return @annotations || {} if annotations_hash.nil?
545
320
 
546
- if set_op
547
- process_set_operation(set_op, nested_rules)
548
- return
321
+ @annotations = annotations_hash
549
322
  end
550
323
 
551
- # If no direct Set operation, look for Key operations in the rule structure
552
- key_op = and_rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Key) }
553
- return unless key_op && key_op.rule.is_a?(Dry::Logic::Operations::And)
554
-
555
- # Look for Set operations in the Key operation's rule
556
- set_op = key_op.rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Set) }
557
- process_set_operation(set_op, nested_rules) if set_op
558
- end
559
-
560
- # Process a set operation
561
- def process_set_operation(set_op, nested_rules)
562
- # Process each rule in the Set operation
563
- set_op.rules.each do |set_rule|
564
- next unless set_rule.is_a?(Dry::Logic::Operations::And) ||
565
- set_rule.is_a?(Dry::Logic::Operations::Implication)
566
-
567
- # For Implication (optional fields), we need to check the right side
568
- if set_rule.is_a?(Dry::Logic::Operations::Implication)
569
- process_nested_rule(set_rule.right, nested_rules, true)
570
- else
571
- process_nested_rule(set_rule, nested_rules, false)
572
- end
324
+ def authorize(&block)
325
+ @authorization_blocks ||= []
326
+ @authorization_blocks.push block
573
327
  end
574
- end
575
-
576
- # Process a nested rule
577
- def process_nested_rule(rule, nested_rules, is_optional)
578
- # Find the key operation which contains the nested key name
579
- nested_key_op = find_nested_key_op(rule)
580
- return unless nested_key_op
581
-
582
- # Get the nested key name
583
- nested_key = nested_key_op.respond_to?(:path) ? nested_key_op.path : nil
584
- return unless nested_key
585
328
 
586
- # Add to nested rules
587
- add_to_nested_rules(nested_key, nested_key_op, nested_rules, is_optional)
588
- end
589
-
590
- # Find nested key operation
591
- def find_nested_key_op(rule)
592
- if rule.is_a?(Dry::Logic::Operations::And)
593
- rule.rules.find { |r| r.is_a?(Dry::Logic::Operations::Key) }
594
- elsif rule.is_a?(Dry::Logic::Operations::Key)
595
- rule
329
+ def call(**args)
330
+ raise NotImplementedError, 'Subclasses must implement the call method'
596
331
  end
597
- end
598
-
599
- # Add to nested rules
600
- def add_to_nested_rules(nested_key, nested_key_op, nested_rules, is_optional)
601
- nested_rules[nested_key] = if is_optional
602
- # For optional fields, create an Implication wrapper
603
- create_implication(nested_key_op.rule)
604
- else
605
- nested_key_op.rule
606
- end
607
- end
608
-
609
- # Create implication
610
- def create_implication(rule)
611
- # We don't need to create a new Key operation, just use the existing rule
612
- Dry::Logic::Operations::Implication.new(
613
- Dry::Logic::Rule.new(proc { true }), # Always true condition
614
- rule
615
- )
616
- end
617
- end
618
-
619
- # SchemaCompiler class for converting Dry::Schema to JSON Schema
620
- class SchemaCompiler
621
- include SchemaMetadataExtractor
622
- include RuleTypeDetector
623
- include PredicateHandler
624
- include BasicTypePredicateHandler
625
- include FormatPredicateHandler
626
- include NestedRuleHandler
627
-
628
- def initialize
629
- @json_schema = {
630
- type: 'object',
631
- properties: {},
632
- required: []
633
- }
634
- end
635
-
636
- attr_reader :json_schema
637
-
638
- def process(schema)
639
- # Reset schema for each process call
640
- @json_schema = {
641
- type: 'object',
642
- properties: {},
643
- required: []
644
- }
645
-
646
- # Store the schema for later use
647
- @schema = schema
648
332
 
649
- # Extract metadata from the schema
650
- @metadata = extract_metadata_from_schema(schema)
651
-
652
- # Process each rule in the schema
653
- schema.rules.each do |key, rule|
654
- process_rule(key, rule)
333
+ def input_schema_to_json
334
+ SchemaMetadataProcessor.process(@input_schema, @collected_metadata || {})
655
335
  end
656
-
657
- # Remove empty required array
658
- @json_schema.delete(:required) if @json_schema[:required].empty?
659
-
660
- @json_schema
661
336
  end
662
337
 
663
- def process_rule(key, rule)
664
- # Skip if this property is hidden
665
- return if @metadata.dig(key.to_s, :hidden) == true
666
-
667
- # Initialize property if it doesn't exist
668
- @json_schema[:properties][key] ||= {}
669
-
670
- # Add to required array if not optional
671
- @json_schema[:required] << key.to_s unless rule.is_a?(Dry::Logic::Operations::Implication)
672
-
673
- # Process predicates to determine type and constraints
674
- extract_predicates(rule, key)
675
-
676
- # Add description if available
677
- description = @metadata.dig(key.to_s, :description)
678
- @json_schema[:properties][key][:description] = description unless description && description.empty?
679
-
680
- # Check if this is a hash type
681
- is_hash = hash_type?(rule)
682
-
683
- # Override type for hash types - do this AFTER extract_predicates
684
- return unless is_hash
685
-
686
- @json_schema[:properties][key][:type] = 'object'
687
- # Process nested schema if this is a hash type
688
- process_nested_schema(key, rule)
689
- end
690
-
691
- def process_nested_schema(key, rule)
692
- # Extract nested schema structure
693
- nested_rules = extract_nested_rules(rule)
694
- return if nested_rules.empty?
695
-
696
- # Initialize nested properties
697
- @json_schema[:properties][key][:properties] ||= {}
698
- @json_schema[:properties][key][:required] ||= []
699
-
700
- # Process each nested rule
701
- nested_rules.each do |nested_key, nested_rule|
702
- process_nested_property(key, nested_key, nested_rule)
703
- end
704
-
705
- # Remove empty required array
706
- return unless @json_schema[:properties][key][:required].empty?
707
-
708
- @json_schema[:properties][key].delete(:required)
338
+ def initialize(headers: {})
339
+ @_meta = {}
340
+ @headers = headers
709
341
  end
710
342
 
711
- def process_nested_property(key, nested_key, nested_rule)
712
- # Initialize nested property
713
- @json_schema[:properties][key][:properties][nested_key] ||= {}
343
+ def authorized?(**args)
344
+ auth_checks = self.class.ancestors.filter_map do |ancestor|
345
+ ancestor.ancestors.include?(FastMcp::Tool) &&
346
+ ancestor.instance_variable_get(:@authorization_blocks)
347
+ end.flatten
714
348
 
715
- # Add to required array if not optional
716
- unless nested_rule.is_a?(Dry::Logic::Operations::Implication)
717
- @json_schema[:properties][key][:required] << nested_key.to_s
718
- end
719
-
720
- # Process predicates for nested property
721
- extract_predicates(nested_rule, nested_key, @json_schema[:properties][key][:properties])
349
+ return true if auth_checks.empty?
722
350
 
723
- # Add description if available for nested property
724
- nested_key_path = "#{key}.#{nested_key}"
725
- description = @metadata.dig(nested_key_path, :description)
726
- unless description && description.empty?
727
- @json_schema[:properties][key][:properties][nested_key][:description] = description
728
- end
351
+ arg_validation = self.class.input_schema.call(args)
352
+ raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any?
729
353
 
730
- # Special case for the test with person.first_name and person.last_name
731
- if key == :person && [:first_name, :last_name].include?(nested_key)
732
- description_text = nested_key == :first_name ? 'First name of the person' : 'Last name of the person'
733
- @json_schema[:properties][key][:properties][nested_key][:description] = description_text
354
+ auth_checks.all? do |auth_check|
355
+ if auth_check.parameters.empty?
356
+ instance_exec(&auth_check)
357
+ else
358
+ instance_exec(**args, &auth_check)
359
+ end
734
360
  end
735
-
736
- # Check if this is a nested hash type
737
- return unless hash_type?(nested_rule)
738
-
739
- @json_schema[:properties][key][:properties][nested_key][:type] = 'object'
740
- # Process deeper nesting
741
- process_deeper_nested_schema(key, nested_key, nested_rule)
742
361
  end
743
362
 
744
- def process_deeper_nested_schema(key, nested_key, nested_rule)
745
- # Extract deeper nested schema structure
746
- deeper_nested_rules = extract_nested_rules(nested_rule)
747
- return if deeper_nested_rules.empty?
748
-
749
- # Initialize deeper nested properties
750
- @json_schema[:properties][key][:properties][nested_key][:properties] ||= {}
751
- @json_schema[:properties][key][:properties][nested_key][:required] ||= []
752
-
753
- # Process each deeper nested rule
754
- deeper_nested_rules.each do |deeper_key, deeper_rule|
755
- process_deeper_nested_property(key, nested_key, deeper_key, deeper_rule)
756
- end
757
-
758
- # Remove empty required array
759
- return unless @json_schema[:properties][key][:properties][nested_key][:required].empty?
363
+ attr_accessor :_meta
364
+ attr_reader :headers
760
365
 
761
- @json_schema[:properties][key][:properties][nested_key].delete(:required)
366
+ def notify_resource_updated(uri)
367
+ self.class.server.notify_resource_updated(uri)
762
368
  end
763
369
 
764
- def process_deeper_nested_property(key, nested_key, deeper_key, deeper_rule)
765
- # Initialize deeper nested property
766
- @json_schema[:properties][key][:properties][nested_key][:properties][deeper_key] ||= {}
767
-
768
- # Add to required array if not optional
769
- unless deeper_rule.is_a?(Dry::Logic::Operations::Implication)
770
- @json_schema[:properties][key][:properties][nested_key][:required] << deeper_key.to_s
771
- end
370
+ def call_with_schema_validation!(**args)
371
+ arg_validation = self.class.input_schema.call(args)
372
+ raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any?
772
373
 
773
- # Process predicates for deeper nested property
774
- extract_predicates(
775
- deeper_rule,
776
- deeper_key,
777
- @json_schema[:properties][key][:properties][nested_key][:properties]
778
- )
779
-
780
- # Add description if available in the deeper nested schema
781
- if deeper_rule.respond_to?(:schema) &&
782
- deeper_rule.schema.respond_to?(:schema_dsl) &&
783
- deeper_rule.schema.schema_dsl.respond_to?(:meta_data)
784
-
785
- meta_data = deeper_rule.schema.schema_dsl.meta_data
786
- if meta_data.key?(deeper_key) && meta_data[deeper_key].key?(:description)
787
- @json_schema[:properties][key][:properties][nested_key][:properties][deeper_key][:description] =
788
- meta_data[deeper_key][:description]
789
- end
790
- end
374
+ # When calling the tool, its metadata can be altered to be returned in response.
375
+ # We return the altered metadata with the tool's result
376
+ [call(**args), _meta]
791
377
  end
792
378
  end
793
379
  end