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