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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +115 -0
- data/LICENSE +21 -0
- data/README.md +440 -0
- data/lib/fast_mcp.rb +189 -0
- data/lib/generators/fast_mcp/install/install_generator.rb +50 -0
- data/lib/generators/fast_mcp/install/templates/application_resource.rb +5 -0
- data/lib/generators/fast_mcp/install/templates/application_tool.rb +5 -0
- data/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +42 -0
- data/lib/generators/fast_mcp/install/templates/sample_resource.rb +12 -0
- data/lib/generators/fast_mcp/install/templates/sample_tool.rb +23 -0
- data/lib/mcp/logger.rb +32 -0
- data/lib/mcp/railtie.rb +45 -0
- data/lib/mcp/resource.rb +210 -0
- data/lib/mcp/server.rb +499 -0
- data/lib/mcp/server_filtering.rb +80 -0
- data/lib/mcp/tool.rb +867 -0
- data/lib/mcp/transports/authenticated_rack_transport.rb +71 -0
- data/lib/mcp/transports/base_transport.rb +40 -0
- data/lib/mcp/transports/rack_transport.rb +627 -0
- data/lib/mcp/transports/stdio_transport.rb +62 -0
- data/lib/mcp/version.rb +5 -0
- metadata +151 -0
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
|