fast-mcp 1.3.2 → 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.
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastMcp
4
+ # Module for handling server filtering functionality
5
+ module ServerFiltering
6
+ # Add filter for tools
7
+ def filter_tools(&block)
8
+ @tool_filters << block if block_given?
9
+ end
10
+
11
+ # Add filter for resources
12
+ def filter_resources(&block)
13
+ @resource_filters << block if block_given?
14
+ end
15
+
16
+ # Check if filters are configured
17
+ def contains_filters?
18
+ @tool_filters.any? || @resource_filters.any?
19
+ end
20
+
21
+ # Create a filtered copy for a specific request
22
+ def create_filtered_copy(request)
23
+ filtered_server = self.class.new(
24
+ name: @name,
25
+ version: @version,
26
+ logger: @logger,
27
+ capabilities: @capabilities
28
+ )
29
+
30
+ # Copy transport settings
31
+ filtered_server.transport_klass = @transport_klass
32
+
33
+ # Apply filters and register items
34
+ register_filtered_tools(filtered_server, request)
35
+ register_filtered_resources(filtered_server, request)
36
+
37
+ filtered_server
38
+ end
39
+
40
+ private
41
+
42
+ # Apply tool filters and register filtered tools
43
+ def register_filtered_tools(filtered_server, request)
44
+ filtered_tools = apply_tool_filters(request)
45
+
46
+ # Register filtered tools
47
+ filtered_tools.each do |tool|
48
+ filtered_server.register_tool(tool)
49
+ end
50
+ end
51
+
52
+ # Apply resource filters and register filtered resources
53
+ def register_filtered_resources(filtered_server, request)
54
+ filtered_resources = apply_resource_filters(request)
55
+
56
+ # Register filtered resources
57
+ filtered_resources.each do |resource|
58
+ filtered_server.register_resource(resource)
59
+ end
60
+ end
61
+
62
+ # Apply all tool filters to the tools collection
63
+ def apply_tool_filters(request)
64
+ filtered_tools = @tools.values
65
+ @tool_filters.each do |filter|
66
+ filtered_tools = filter.call(request, filtered_tools)
67
+ end
68
+ filtered_tools
69
+ end
70
+
71
+ # Apply all resource filters to the resources collection
72
+ def apply_resource_filters(request)
73
+ filtered_resources = @resources
74
+ @resource_filters.each do |filter|
75
+ filtered_resources = filter.call(request, filtered_resources)
76
+ end
77
+ filtered_resources
78
+ end
79
+ end
80
+ end
data/lib/mcp/tool.rb CHANGED
@@ -11,6 +11,14 @@ module Dry
11
11
  def description(text)
12
12
  key_name = name.to_sym
13
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
+
14
22
  self
15
23
  end
16
24
  end
@@ -20,6 +28,14 @@ module Dry
20
28
  def description(text)
21
29
  key_name = name.to_sym
22
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
+
23
39
  self
24
40
  end
25
41
  end
@@ -29,6 +45,14 @@ module Dry
29
45
  def description(text)
30
46
  key_name = name.to_sym
31
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
+
32
56
  self
33
57
  end
34
58
  end
@@ -70,6 +94,27 @@ module FastMcp
70
94
  class << self
71
95
  attr_accessor :server
72
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
+
73
118
  def arguments(&block)
74
119
  @input_schema = Dry::Schema.JSON(&block)
75
120
  end
@@ -90,6 +135,11 @@ module FastMcp
90
135
  @description = description
91
136
  end
92
137
 
138
+ def authorize(&block)
139
+ @authorization_blocks ||= []
140
+ @authorization_blocks.push block
141
+ end
142
+
93
143
  def call(**args)
94
144
  raise NotImplementedError, 'Subclasses must implement the call method'
95
145
  end
@@ -102,6 +152,34 @@ module FastMcp
102
152
  end
103
153
  end
104
154
 
155
+ def initialize(headers: {})
156
+ @_meta = {}
157
+ @headers = headers
158
+ end
159
+
160
+ def authorized?(**args)
161
+ auth_checks = self.class.ancestors.filter_map do |ancestor|
162
+ ancestor.ancestors.include?(FastMcp::Tool) &&
163
+ ancestor.instance_variable_get(:@authorization_blocks)
164
+ end.flatten
165
+
166
+ return true if auth_checks.empty?
167
+
168
+ arg_validation = self.class.input_schema.call(args)
169
+ raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any?
170
+
171
+ auth_checks.all? do |auth_check|
172
+ if auth_check.parameters.empty?
173
+ instance_exec(&auth_check)
174
+ else
175
+ instance_exec(**args, &auth_check)
176
+ end
177
+ end
178
+ end
179
+
180
+ attr_accessor :_meta
181
+ attr_reader :headers
182
+
105
183
  def notify_resource_updated(uri)
106
184
  self.class.server.notify_resource_updated(uri)
107
185
  end
@@ -110,106 +188,93 @@ module FastMcp
110
188
  arg_validation = self.class.input_schema.call(args)
111
189
  raise InvalidArgumentsError, arg_validation.errors.to_h.to_json if arg_validation.errors.any?
112
190
 
113
- call(**args)
191
+ # When calling the tool, its metadata can be altered to be returned in response.
192
+ # We return the altered metadata with the tool's result
193
+ [call(**args), _meta]
114
194
  end
115
195
  end
116
196
 
117
- # Module for handling schema descriptions
118
- module SchemaDescriptionExtractor
119
- # Extract descriptions from a schema
120
- def extract_descriptions_from_schema(schema)
121
- descriptions = {}
197
+ # Module for handling schema metadata
198
+ module SchemaMetadataExtractor
199
+ # Extract metadata from a schema
200
+ def extract_metadata_from_schema(schema)
201
+ # a deeply-assignable hash, the default value of a key is {}
202
+ metadata = Hash.new { |h, k| h[k] = Hash.new(&h.default_proc) }
122
203
 
123
204
  # Extract descriptions from the top-level schema
124
205
  if schema.respond_to?(:schema_dsl) && schema.schema_dsl.respond_to?(:meta_data)
125
206
  schema.schema_dsl.meta_data.each do |key, meta|
126
- descriptions[key.to_s] = meta[:description] if meta[:description]
207
+ metadata[key.to_s][:description] = meta[:description] if meta[:description]
208
+ metadata[key.to_s][:hidden] = meta[:hidden]
127
209
  end
128
210
  end
129
211
 
130
- # Extract descriptions from nested schemas using AST
212
+ # Extract metadata from nested schemas using AST
131
213
  schema.rules.each_value do |rule|
132
214
  next unless rule.respond_to?(:ast)
133
215
 
134
- extract_descriptions_from_ast(rule.ast, descriptions)
216
+ extract_metadata_from_ast(rule.ast, metadata)
135
217
  end
136
218
 
137
- # Special case for the nested properties test
138
- handle_special_case_for_person(schema, descriptions)
139
-
140
- descriptions
219
+ metadata
141
220
  end
142
221
 
143
- # Handle special case for person schema in tests
144
- def handle_special_case_for_person(schema, descriptions)
145
- return unless schema.rules.key?(:person) &&
146
- schema.rules[:person].respond_to?(:rule) &&
147
- schema.rules[:person].rule.is_a?(Dry::Logic::Operations::And)
148
-
149
- # Check if this is the test schema with person.first_name and person.last_name
150
- person_rule = schema.rules[:person]
151
- return unless person_rule.rule.rules.any? { |r| r.is_a?(Dry::Logic::Operations::Set) }
152
-
153
- descriptions['person.first_name'] = 'First name of the person'
154
- descriptions['person.last_name'] = 'Last name of the person'
155
- end
156
-
157
- # Extract descriptions from AST
158
- def extract_descriptions_from_ast(ast, descriptions, parent_key = nil)
222
+ # Extract metadata from AST
223
+ def extract_metadata_from_ast(ast, metadata, parent_key = nil)
159
224
  return unless ast.is_a?(Array)
160
225
 
161
- process_key_node(ast, descriptions, parent_key) if ast[0] == :key
162
- process_set_node(ast, descriptions, parent_key) if ast[0] == :set
163
- process_and_node(ast, descriptions, parent_key) if ast[0] == :and
226
+ process_key_node(ast, metadata, parent_key) if ast[0] == :key
227
+ process_set_node(ast, metadata, parent_key) if ast[0] == :set
228
+ process_and_node(ast, metadata, parent_key) if ast[0] == :and
164
229
  end
165
230
 
166
231
  # Process a key node in the AST
167
- def process_key_node(ast, descriptions, parent_key)
232
+ def process_key_node(ast, metadata, parent_key)
168
233
  return unless ast[1].is_a?(Array) && ast[1].size >= 2
169
234
 
170
235
  key = ast[1][0]
171
236
  full_key = parent_key ? "#{parent_key}.#{key}" : key.to_s
172
237
 
173
238
  # Process nested AST
174
- extract_descriptions_from_ast(ast[1][1], descriptions, full_key) if ast[1][1].is_a?(Array)
239
+ extract_metadata_from_ast(ast[1][1], metadata, full_key) if ast[1][1].is_a?(Array)
175
240
  end
176
241
 
177
242
  # Process a set node in the AST
178
- def process_set_node(ast, descriptions, parent_key)
243
+ def process_set_node(ast, metadata, parent_key)
179
244
  return unless ast[1].is_a?(Array)
180
245
 
181
246
  ast[1].each do |set_node|
182
- extract_descriptions_from_ast(set_node, descriptions, parent_key)
247
+ extract_metadata_from_ast(set_node, metadata, parent_key)
183
248
  end
184
249
  end
185
250
 
186
251
  # Process an and node in the AST
187
- def process_and_node(ast, descriptions, parent_key)
252
+ def process_and_node(ast, metadata, parent_key)
188
253
  return unless ast[1].is_a?(Array)
189
254
 
190
255
  # Process each child node
191
256
  ast[1].each do |and_node|
192
- extract_descriptions_from_ast(and_node, descriptions, parent_key)
257
+ extract_metadata_from_ast(and_node, metadata, parent_key)
193
258
  end
194
259
 
195
260
  # Process nested properties
196
- process_nested_properties(ast, descriptions, parent_key)
261
+ process_nested_properties(ast, metadata, parent_key)
197
262
  end
198
263
 
199
264
  # Process nested properties in an and node
200
- def process_nested_properties(ast, descriptions, parent_key)
265
+ def process_nested_properties(ast, metadata, parent_key)
201
266
  ast[1].each do |node|
202
267
  next unless node[0] == :key && node[1].is_a?(Array) && node[1][1].is_a?(Array) && node[1][1][0] == :and
203
268
 
204
269
  key_name = node[1][0]
205
270
  nested_key = parent_key ? "#{parent_key}.#{key_name}" : key_name.to_s
206
271
 
207
- process_nested_schema_ast(node[1][1], descriptions, nested_key)
272
+ process_nested_schema_ast(node[1][1], metadata, nested_key)
208
273
  end
209
274
  end
210
275
 
211
276
  # Process a nested schema
212
- def process_nested_schema_ast(ast, descriptions, nested_key)
277
+ def process_nested_schema_ast(ast, metadata, nested_key)
213
278
  return unless ast[1].is_a?(Array)
214
279
 
215
280
  ast[1].each do |subnode|
@@ -218,31 +283,31 @@ module FastMcp
218
283
  subnode[1].each do |set_node|
219
284
  next unless set_node[0] == :and && set_node[1].is_a?(Array)
220
285
 
221
- process_nested_keys(set_node, descriptions, nested_key)
286
+ process_nested_keys(set_node, metadata, nested_key)
222
287
  end
223
288
  end
224
289
  end
225
290
 
226
291
  # Process nested keys in a schema
227
- def process_nested_keys(set_node, descriptions, nested_key)
292
+ def process_nested_keys(set_node, metadata, nested_key)
228
293
  set_node[1].each do |and_node|
229
294
  next unless and_node[0] == :key && and_node[1].is_a?(Array) && and_node[1].size >= 2
230
295
 
231
296
  nested_field = and_node[1][0]
232
297
  nested_path = "#{nested_key}.#{nested_field}"
233
298
 
234
- extract_meta_description(and_node, descriptions, nested_path)
299
+ extract_metadata(and_node, metadata, nested_path)
235
300
  end
236
301
  end
237
302
 
238
- # Extract meta description from a node
239
- def extract_meta_description(and_node, descriptions, nested_path)
303
+ # Extract metadata from a node
304
+ def extract_metadata(and_node, metadata, nested_path)
240
305
  return unless and_node[1][1].is_a?(Array) && and_node[1][1][1].is_a?(Array)
241
306
 
242
307
  and_node[1][1][1].each do |meta_node|
243
308
  next unless meta_node[0] == :meta && meta_node[1].is_a?(Hash) && meta_node[1][:description]
244
309
 
245
- descriptions[nested_path] = meta_node[1][:description]
310
+ metadata[nested_path] = meta_node[1][:description]
246
311
  end
247
312
  end
248
313
  end
@@ -251,9 +316,7 @@ module FastMcp
251
316
  module RuleTypeDetector
252
317
  # Check if a rule is for a hash type
253
318
  def hash_type?(rule)
254
- return true if direct_hash_predicate?(rule)
255
- return true if nested_hash_predicate?(rule)
256
- return true if special_case_hash?(rule)
319
+ return true if direct_hash_predicate?(rule) || nested_hash_predicate?(rule)
257
320
 
258
321
  false
259
322
  end
@@ -279,28 +342,6 @@ module FastMcp
279
342
  false
280
343
  end
281
344
 
282
- # Check for special case hash
283
- def special_case_hash?(rule)
284
- # Special case for schema_compiler_spec.rb tests
285
- return true if rule.respond_to?(:path) && [:metadata, :user].include?(rule.path)
286
-
287
- # Special case for person hash in the test
288
- return false unless rule.respond_to?(:ast)
289
-
290
- ast = rule.ast
291
- return false unless ast[0] == :and && ast[1].is_a?(Array)
292
-
293
- ast[1].each do |node|
294
- next unless node[0] == :key && node[1].is_a?(Array) && node[1][1].is_a?(Array) && node[1][1][0] == :and
295
-
296
- node[1][1][1].each do |subnode|
297
- return true if subnode[0] == :predicate && subnode[1].is_a?(Array) && subnode[1][0] == :hash?
298
- end
299
- end
300
-
301
- false
302
- end
303
-
304
345
  # Check if a rule is for an array type
305
346
  def array_type?(rule)
306
347
  rule.is_a?(Dry::Logic::Operations::And) &&
@@ -625,7 +666,7 @@ module FastMcp
625
666
 
626
667
  # SchemaCompiler class for converting Dry::Schema to JSON Schema
627
668
  class SchemaCompiler
628
- include SchemaDescriptionExtractor
669
+ include SchemaMetadataExtractor
629
670
  include RuleTypeDetector
630
671
  include PredicateHandler
631
672
  include BasicTypePredicateHandler
@@ -653,8 +694,8 @@ module FastMcp
653
694
  # Store the schema for later use
654
695
  @schema = schema
655
696
 
656
- # Extract descriptions from the schema
657
- @descriptions = extract_descriptions_from_schema(schema)
697
+ # Extract metadata from the schema
698
+ @metadata = extract_metadata_from_schema(schema)
658
699
 
659
700
  # Process each rule in the schema
660
701
  schema.rules.each do |key, rule|
@@ -668,6 +709,9 @@ module FastMcp
668
709
  end
669
710
 
670
711
  def process_rule(key, rule)
712
+ # Skip if this property is hidden
713
+ return if @metadata.dig(key.to_s, :hidden) == true
714
+
671
715
  # Initialize property if it doesn't exist
672
716
  @json_schema[:properties][key] ||= {}
673
717
 
@@ -678,7 +722,8 @@ module FastMcp
678
722
  extract_predicates(rule, key)
679
723
 
680
724
  # Add description if available
681
- @json_schema[:properties][key][:description] = @descriptions[key.to_s] if @descriptions.key?(key.to_s)
725
+ description = @metadata.dig(key.to_s, :description)
726
+ @json_schema[:properties][key][:description] = description unless description && description.empty?
682
727
 
683
728
  # Check if this is a hash type
684
729
  is_hash = hash_type?(rule)
@@ -725,8 +770,9 @@ module FastMcp
725
770
 
726
771
  # Add description if available for nested property
727
772
  nested_key_path = "#{key}.#{nested_key}"
728
- if @descriptions.key?(nested_key_path)
729
- @json_schema[:properties][key][:properties][nested_key][:description] = @descriptions[nested_key_path]
773
+ description = @metadata.dig(nested_key_path, :description)
774
+ unless description && description.empty?
775
+ @json_schema[:properties][key][:properties][nested_key][:description] = description
730
776
  end
731
777
 
732
778
  # Special case for the test with person.first_name and person.last_name
@@ -32,8 +32,8 @@ module FastMcp
32
32
 
33
33
  # Process an incoming message
34
34
  # This is a helper method that can be used by subclasses
35
- def process_message(message)
36
- server.handle_json_request(message)
35
+ def process_message(message, headers: {})
36
+ server.handle_request(message, headers: headers)
37
37
  end
38
38
  end
39
39
  end