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.
data/lib/mcp/server.rb CHANGED
@@ -8,9 +8,12 @@ require_relative 'transports/stdio_transport'
8
8
  require_relative 'transports/rack_transport'
9
9
  require_relative 'transports/authenticated_rack_transport'
10
10
  require_relative 'logger'
11
+ require_relative 'server_filtering'
11
12
 
12
13
  module FastMcp
13
14
  class Server
15
+ include ServerFiltering
16
+
14
17
  attr_reader :name, :version, :tools, :resources, :capabilities
15
18
 
16
19
  DEFAULT_CAPABILITIES = {
@@ -27,14 +30,16 @@ module FastMcp
27
30
  @name = name
28
31
  @version = version
29
32
  @tools = {}
30
- @resources = {}
33
+ @resources = []
31
34
  @resource_subscriptions = {}
32
35
  @logger = logger
33
- @logger.level = Logger::INFO
34
36
  @request_id = 0
35
37
  @transport_klass = nil
36
38
  @transport = nil
37
39
  @capabilities = DEFAULT_CAPABILITIES.dup
40
+ @tool_filters = []
41
+ @resource_filters = []
42
+ @on_error_result = nil
38
43
 
39
44
  # Merge with provided capabilities
40
45
  @capabilities.merge!(capabilities) if capabilities.is_a?(Hash)
@@ -66,8 +71,9 @@ module FastMcp
66
71
 
67
72
  # Register a resource with the server
68
73
  def register_resource(resource)
69
- @resources[resource.uri] = resource
70
- @logger.debug("Registered resource: #{resource.name} (#{resource.uri})")
74
+ @resources << resource
75
+
76
+ @logger.debug("Registered resource: #{resource.resource_name} (#{resource.uri})")
71
77
  resource.server = self
72
78
  # Notify subscribers about the list change
73
79
  notify_resource_list_changed if @transport
@@ -75,10 +81,16 @@ module FastMcp
75
81
  resource
76
82
  end
77
83
 
84
+ def on_error_result(&block)
85
+ @on_error_result = block
86
+ end
87
+
78
88
  # Remove a resource from the server
79
89
  def remove_resource(uri)
80
- if @resources.key?(uri)
81
- resource = @resources.delete(uri)
90
+ resource = @resources.find { |r| r.uri == uri }
91
+
92
+ if resource
93
+ @resources.delete(resource)
82
94
  @logger.debug("Removed resource: #{resource.name} (#{uri})")
83
95
 
84
96
  # Notify subscribers about the list change
@@ -95,7 +107,7 @@ module FastMcp
95
107
  @logger.transport = :stdio
96
108
  @logger.info("Starting MCP server: #{@name} v#{@version}")
97
109
  @logger.info("Available tools: #{@tools.keys.join(', ')}")
98
- @logger.info("Available resources: #{@resources.keys.join(', ')}")
110
+ @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
99
111
 
100
112
  # Use STDIO transport by default
101
113
  @transport_klass = FastMcp::Transports::StdioTransport
@@ -105,35 +117,29 @@ module FastMcp
105
117
 
106
118
  # Start the server as a Rack middleware
107
119
  def start_rack(app, options = {})
108
- @logger.info("Starting MCP server as Rack middleware: #{@name} v#{@version}")
120
+ @transport_klass = options.delete(:transport) || FastMcp::Transports::RackTransport
121
+ transport_name = @transport_klass.name.split('::').last
122
+
123
+ @logger.info("Starting MCP server with #{transport_name}: #{@name} v#{@version}")
109
124
  @logger.info("Available tools: #{@tools.keys.join(', ')}")
110
- @logger.info("Available resources: #{@resources.keys.join(', ')}")
125
+ @logger.info("Available resources: #{@resources.map(&:resource_name).join(', ')}")
111
126
 
112
- # Use Rack transport
113
- transport_klass = FastMcp::Transports::RackTransport
114
- @transport = transport_klass.new(app, self, options.merge(logger: @logger))
127
+ @transport = @transport_klass.new(app, self, options.merge(logger: @logger))
115
128
  @transport.start
116
129
 
117
130
  # Return the transport as middleware
118
131
  @transport
119
132
  end
120
133
 
121
- def start_authenticated_rack(app, options = {})
122
- @logger.info("Starting MCP server as Authenticated Rack middleware: #{@name} v#{@version}")
123
- @logger.info("Available tools: #{@tools.keys.join(', ')}")
124
- @logger.info("Available resources: #{@resources.keys.join(', ')}")
125
-
126
- # Use Rack transport
127
- transport_klass = FastMcp::Transports::AuthenticatedRackTransport
128
- @transport = transport_klass.new(app, self, options.merge(logger: @logger))
129
- @transport.start
134
+ # Handle a JSON-RPC request and return the response as a JSON string
135
+ def handle_json_request(request, headers: {})
136
+ request_str = request.is_a?(String) ? request : JSON.generate(request)
130
137
 
131
- # Return the transport as middleware
132
- @transport
138
+ handle_request(request_str, headers: headers)
133
139
  end
134
140
 
135
141
  # Handle incoming JSON-RPC request
136
- def handle_request(json_str) # rubocop:disable Metrics/MethodLength
142
+ def handle_request(json_str, headers: {}) # rubocop:disable Metrics/MethodLength
137
143
  begin
138
144
  request = JSON.parse(json_str)
139
145
  rescue JSON::ParserError, TypeError
@@ -142,15 +148,13 @@ module FastMcp
142
148
 
143
149
  @logger.debug("Received request: #{request.inspect}")
144
150
 
145
- # Check if it's a valid JSON-RPC 2.0 request
146
- unless request['jsonrpc'] == '2.0' && request['method']
147
- return send_error(-32_600, 'Invalid Request', request['id'])
148
- end
149
-
150
151
  method = request['method']
151
152
  params = request['params'] || {}
152
153
  id = request['id']
153
154
 
155
+ # Check if it's a valid JSON-RPC 2.0 request
156
+ return send_error(-32_600, 'Invalid Request', id) unless request['jsonrpc'] == '2.0'
157
+
154
158
  case method
155
159
  when 'ping'
156
160
  send_result({}, id)
@@ -161,39 +165,26 @@ module FastMcp
161
165
  when 'tools/list'
162
166
  handle_tools_list(id)
163
167
  when 'tools/call'
164
- handle_tools_call(params, id)
168
+ handle_tools_call(params, headers, id)
165
169
  when 'resources/list'
166
170
  handle_resources_list(id)
171
+ when 'resources/templates/list'
172
+ handle_resources_templates_list(id)
167
173
  when 'resources/read'
168
174
  handle_resources_read(params, id)
169
175
  when 'resources/subscribe'
170
176
  handle_resources_subscribe(params, id)
171
177
  when 'resources/unsubscribe'
172
178
  handle_resources_unsubscribe(params, id)
179
+ when nil
180
+ # This is a notification response, we don't need to handle it
181
+ nil
173
182
  else
174
183
  send_error(-32_601, "Method not found: #{method}", id)
175
184
  end
176
185
  rescue StandardError => e
177
186
  @logger.error("Error handling request: #{e.message}, #{e.backtrace.join("\n")}")
178
- send_error(-32_600, "Internal error: #{e.message}", id)
179
- end
180
-
181
- # Handle a JSON-RPC request and return the response as a JSON string
182
- def handle_json_request(request)
183
- # Process the request
184
- if request.is_a?(String)
185
- handle_request(request)
186
- else
187
- handle_request(JSON.generate(request))
188
- end
189
- end
190
-
191
- # Read a resource directly
192
- def read_resource(uri)
193
- resource = @resources[uri]
194
- raise "Resource not found: #{uri}" unless resource
195
-
196
- resource
187
+ send_error(-32_600, "Internal error: #{e.message}, #{e.backtrace.join("\n")}", id)
197
188
  end
198
189
 
199
190
  # Notify subscribers about a resource update
@@ -215,6 +206,10 @@ module FastMcp
215
206
  @transport.send_message(notification)
216
207
  end
217
208
 
209
+ def read_resource(uri)
210
+ @resources.find { |r| r.match(uri) }
211
+ end
212
+
218
213
  private
219
214
 
220
215
  PROTOCOL_VERSION = '2024-11-05'
@@ -249,24 +244,34 @@ module FastMcp
249
244
 
250
245
  return send_error(-32_602, 'Invalid params: missing resource URI', id) unless uri
251
246
 
252
- resource = @resources[uri]
253
- return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
247
+ @logger.debug("Looking for resource with URI: #{uri}")
254
248
 
255
- base_content = { uri: resource.uri }
256
- base_content[:mimeType] = resource.mime_type if resource.mime_type
257
- resource_instance = resource.instance
258
- # Format the response according to the MCP specification
259
- result = if resource_instance.binary?
260
- {
261
- contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))]
262
- }
263
- else
264
- {
265
- contents: [base_content.merge(text: resource_instance.content)]
266
- }
267
- end
268
-
269
- send_result(result, id)
249
+ begin
250
+ resource = read_resource(uri)
251
+ return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
252
+
253
+ @logger.debug("Found resource: #{resource.resource_name}, templated: #{resource.templated?}")
254
+
255
+ base_content = { uri: uri }
256
+ base_content[:mimeType] = resource.mime_type if resource.mime_type
257
+ resource_instance = resource.initialize_from_uri(uri)
258
+ @logger.debug("Resource instance params: #{resource_instance.params.inspect}")
259
+
260
+ result = if resource_instance.binary?
261
+ {
262
+ contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))]
263
+ }
264
+ else
265
+ {
266
+ contents: [base_content.merge(text: resource_instance.content)]
267
+ }
268
+ end
269
+
270
+ # # rescue StandardError => e
271
+ # @logger.error("Error reading resource: #{e.message}")
272
+ # @logger.error(e.backtrace.join("\n"))
273
+ send_result(result, id)
274
+ end
270
275
  end
271
276
 
272
277
  def handle_initialized_notification
@@ -281,18 +286,32 @@ module FastMcp
281
286
  # Handle tools/list request
282
287
  def handle_tools_list(id)
283
288
  tools_list = @tools.values.map do |tool|
284
- {
289
+ tool_info = {
285
290
  name: tool.tool_name,
286
291
  description: tool.description || '',
287
292
  inputSchema: tool.input_schema_to_json || { type: 'object', properties: {}, required: [] }
288
293
  }
294
+
295
+ # Add annotations if they exist
296
+ annotations = tool.annotations
297
+ unless annotations.empty?
298
+ # Convert snake_case keys to camelCase for MCP protocol
299
+ camel_case_annotations = {}
300
+ annotations.each do |key, value|
301
+ camel_key = key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
302
+ camel_case_annotations[camel_key] = value
303
+ end
304
+ tool_info[:annotations] = camel_case_annotations
305
+ end
306
+
307
+ tool_info
289
308
  end
290
309
 
291
310
  send_result({ tools: tools_list }, id)
292
311
  end
293
312
 
294
313
  # Handle tools/call request
295
- def handle_tools_call(params, id)
314
+ def handle_tools_call(params, headers, id)
296
315
  tool_name = params['name']
297
316
  arguments = params['arguments'] || {}
298
317
 
@@ -304,7 +323,13 @@ module FastMcp
304
323
  begin
305
324
  # Convert string keys to symbols for Ruby
306
325
  symbolized_args = symbolize_keys(arguments)
307
- result, metadata = tool.new.call_with_schema_validation!(**symbolized_args)
326
+
327
+ tool_instance = tool.new(headers: headers)
328
+ authorized = tool_instance.authorized?(**symbolized_args)
329
+
330
+ return send_error(-32_602, 'Unauthorized', id) unless authorized
331
+
332
+ result, metadata = tool_instance.call_with_schema_validation!(**symbolized_args)
308
333
 
309
334
  # Format and send the result
310
335
  send_formatted_result(result, id, metadata)
@@ -335,6 +360,8 @@ module FastMcp
335
360
 
336
361
  # Format and send error result
337
362
  def send_error_result(message, id)
363
+ @on_error_result&.call(message)
364
+
338
365
  # Format error according to the MCP specification
339
366
  error_result = {
340
367
  content: [{ type: 'text', text: "Error: #{message}" }],
@@ -346,11 +373,19 @@ module FastMcp
346
373
 
347
374
  # Handle resources/list request
348
375
  def handle_resources_list(id)
349
- resources_list = @resources.values.map(&:metadata)
376
+ resources_list = @resources.select(&:non_templated?).map(&:metadata)
350
377
 
351
378
  send_result({ resources: resources_list }, id)
352
379
  end
353
380
 
381
+ # Handle resources/templates/list request
382
+ def handle_resources_templates_list(id)
383
+ # Collect templated resources
384
+ templated_resources_list = @resources.select(&:templated?).map(&:metadata)
385
+
386
+ send_result({ resourceTemplates: templated_resources_list }, id)
387
+ end
388
+
354
389
  # Handle resources/subscribe request
355
390
  def handle_resources_subscribe(params, id)
356
391
  return unless @client_initialized
@@ -362,11 +397,8 @@ module FastMcp
362
397
  return
363
398
  end
364
399
 
365
- resource = @resources[uri]
366
- unless resource
367
- send_error(-32_602, "Resource not found: #{uri}", id)
368
- return
369
- end
400
+ resource = @resources.find { |r| r.match(uri) }
401
+ return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
370
402
 
371
403
  # Add to subscriptions
372
404
  @resource_subscriptions[uri] ||= []
@@ -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