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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/README.md +52 -4
- data/lib/mcp/resource.rb +86 -29
- data/lib/mcp/server.rb +94 -66
- data/lib/mcp/server_filtering.rb +80 -0
- data/lib/mcp/tool.rb +125 -79
- data/lib/mcp/transports/base_transport.rb +2 -2
- data/lib/mcp/transports/rack_transport.rb +146 -60
- data/lib/mcp/version.rb +1 -1
- metadata +17 -2
@@ -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
|
-
|
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
|
118
|
-
module
|
119
|
-
# Extract
|
120
|
-
def
|
121
|
-
|
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
|
-
|
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
|
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
|
-
|
216
|
+
extract_metadata_from_ast(rule.ast, metadata)
|
135
217
|
end
|
136
218
|
|
137
|
-
|
138
|
-
handle_special_case_for_person(schema, descriptions)
|
139
|
-
|
140
|
-
descriptions
|
219
|
+
metadata
|
141
220
|
end
|
142
221
|
|
143
|
-
#
|
144
|
-
def
|
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,
|
162
|
-
process_set_node(ast,
|
163
|
-
process_and_node(ast,
|
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,
|
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
|
-
|
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,
|
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
|
-
|
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,
|
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
|
-
|
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,
|
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,
|
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],
|
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,
|
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,
|
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,
|
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
|
-
|
299
|
+
extract_metadata(and_node, metadata, nested_path)
|
235
300
|
end
|
236
301
|
end
|
237
302
|
|
238
|
-
# Extract
|
239
|
-
def
|
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
|
-
|
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
|
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
|
657
|
-
@
|
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
|
-
|
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
|
-
|
729
|
-
|
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.
|
35
|
+
def process_message(message, headers: {})
|
36
|
+
server.handle_request(message, headers: headers)
|
37
37
|
end
|
38
38
|
end
|
39
39
|
end
|