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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -5
- data/README.md +78 -22
- data/lib/fast_mcp.rb +3 -14
- data/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +2 -2
- data/lib/generators/fast_mcp/install/templates/sample_tool.rb +7 -0
- data/lib/mcp/railtie.rb +4 -0
- data/lib/mcp/resource.rb +86 -29
- data/lib/mcp/server.rb +107 -75
- data/lib/mcp/server_filtering.rb +80 -0
- data/lib/mcp/tool.rb +218 -632
- data/lib/mcp/transports/base_transport.rb +2 -2
- data/lib/mcp/transports/rack_transport.rb +148 -62
- data/lib/mcp/version.rb +1 -1
- metadata +27 -6
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
|
-
|
73
|
-
|
74
|
-
|
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
|
-
|
83
|
-
|
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
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
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
|
-
|
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
|
-
|
165
|
-
|
166
|
-
|
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
|
-
|
172
|
-
|
118
|
+
@metadata[full_path] ||= {}
|
119
|
+
@metadata[full_path][meta_key] = value
|
120
|
+
end
|
173
121
|
|
174
|
-
|
175
|
-
|
176
|
-
|
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
|
-
|
179
|
-
|
180
|
-
|
181
|
-
end
|
129
|
+
def current_path
|
130
|
+
@nesting_stack.dup
|
131
|
+
end
|
182
132
|
|
183
|
-
|
184
|
-
|
185
|
-
|
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
|
-
|
188
|
-
|
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
|
-
|
191
|
-
|
192
|
-
|
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
|
-
|
195
|
-
|
196
|
-
|
156
|
+
# Store in current context if available
|
157
|
+
context = MetadataContext.current
|
158
|
+
return unless context
|
197
159
|
|
198
|
-
|
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
|
-
|
208
|
-
|
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
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
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
|
-
|
229
|
-
|
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
|
-
|
233
|
-
|
178
|
+
# Merge traditional metadata with collected nested metadata
|
179
|
+
all_metadata = merge_metadata(metadata, collected_metadata)
|
234
180
|
|
235
|
-
|
236
|
-
|
181
|
+
apply_metadata_to_schema(base_schema, all_metadata)
|
182
|
+
end
|
237
183
|
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
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
|
-
|
244
|
-
|
245
|
-
|
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
|
-
|
249
|
-
|
193
|
+
# Start with traditional metadata
|
194
|
+
merged = traditional.dup
|
250
195
|
|
251
|
-
|
252
|
-
|
196
|
+
# Add collected metadata with full paths
|
197
|
+
filtered_collected.each do |path, metadata|
|
198
|
+
merged[path] = metadata
|
253
199
|
end
|
254
200
|
|
255
|
-
|
256
|
-
|
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
|
-
|
260
|
-
|
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
|
-
|
263
|
-
|
264
|
-
|
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
|
-
|
268
|
-
|
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
|
-
|
274
|
-
|
215
|
+
properties.each do |property_name, property_schema|
|
216
|
+
current_path = (path_prefix + [property_name.to_s]).join('.')
|
275
217
|
|
276
|
-
|
277
|
-
|
278
|
-
|
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
|
-
|
281
|
-
|
222
|
+
# Skip hidden properties entirely
|
223
|
+
next if property_metadata&.dig(:hidden)
|
282
224
|
|
283
|
-
|
284
|
-
|
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
|
-
|
290
|
-
|
291
|
-
|
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
|
-
|
245
|
+
filtered_properties[property_name] = property_schema
|
295
246
|
end
|
296
247
|
|
297
|
-
|
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
|
-
|
305
|
-
|
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
|
-
|
332
|
-
|
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
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
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
|
-
|
393
|
-
|
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
|
-
|
405
|
-
|
406
|
-
|
407
|
-
|
408
|
-
|
409
|
-
|
410
|
-
|
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
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
|
426
|
-
|
427
|
-
|
428
|
-
|
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
|
-
|
435
|
-
|
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
|
-
|
445
|
-
|
446
|
-
|
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
|
-
|
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
|
-
|
475
|
-
|
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
|
-
|
490
|
-
|
303
|
+
def tool_name(name = nil)
|
304
|
+
name = @name || self.name if name.nil?
|
305
|
+
return if name.nil?
|
491
306
|
|
492
|
-
|
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
|
-
|
498
|
-
process_set_operation(set_op, nested_rules)
|
499
|
-
return
|
309
|
+
@name = name
|
500
310
|
end
|
501
311
|
|
502
|
-
|
503
|
-
|
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
|
-
|
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
|
-
|
537
|
-
|
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
|
-
|
547
|
-
process_set_operation(set_op, nested_rules)
|
548
|
-
return
|
321
|
+
@annotations = annotations_hash
|
549
322
|
end
|
550
323
|
|
551
|
-
|
552
|
-
|
553
|
-
|
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
|
-
|
587
|
-
|
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
|
-
|
650
|
-
|
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
|
664
|
-
|
665
|
-
|
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
|
712
|
-
|
713
|
-
|
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
|
-
|
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
|
-
|
724
|
-
|
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
|
-
|
731
|
-
|
732
|
-
|
733
|
-
|
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
|
-
|
745
|
-
|
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
|
-
|
366
|
+
def notify_resource_updated(uri)
|
367
|
+
self.class.server.notify_resource_updated(uri)
|
762
368
|
end
|
763
369
|
|
764
|
-
def
|
765
|
-
|
766
|
-
|
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
|
-
#
|
774
|
-
|
775
|
-
|
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
|