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.
@@ -13,6 +13,7 @@ module FastMcp
13
13
  DEFAULT_PATH_PREFIX = '/mcp'
14
14
  DEFAULT_ALLOWED_ORIGINS = ['localhost', '127.0.0.1', '[::1]'].freeze
15
15
  DEFAULT_ALLOWED_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].freeze
16
+ SERVER_ENV_KEY = 'fast_mcp.server'
16
17
 
17
18
  SSE_HEADERS = {
18
19
  'Content-Type' => 'text/event-stream',
@@ -40,8 +41,10 @@ module FastMcp
40
41
  @allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
41
42
  @localhost_only = options.fetch(:localhost_only, true) # Default to localhost-only mode
42
43
  @allowed_ips = options[:allowed_ips] || DEFAULT_ALLOWED_IPS
43
- @sse_clients = {}
44
+ @sse_clients = Concurrent::Hash.new
45
+ @sse_clients_mutex = Mutex.new
44
46
  @running = false
47
+ @filtered_servers_cache = {}
45
48
  end
46
49
 
47
50
  # Start the transport
@@ -57,12 +60,14 @@ module FastMcp
57
60
  @running = false
58
61
 
59
62
  # Close all SSE connections
60
- @sse_clients.each_value do |client|
61
- client[:stream].close if client[:stream].respond_to?(:close) && !client[:stream].closed?
62
- rescue StandardError => e
63
- @logger.error("Error closing SSE connection: #{e.message}")
63
+ @sse_clients_mutex.synchronize do
64
+ @sse_clients.each_value do |client|
65
+ client[:stream].close if client[:stream].respond_to?(:close) && !client[:stream].closed?
66
+ rescue StandardError => e
67
+ @logger.error("Error closing SSE connection: #{e.message}")
68
+ end
69
+ @sse_clients.clear
64
70
  end
65
- @sse_clients.clear
66
71
  end
67
72
 
68
73
  # Send a message to all connected SSE clients
@@ -71,21 +76,25 @@ module FastMcp
71
76
  @logger.debug("Broadcasting message to #{@sse_clients.size} SSE clients: #{json_message}")
72
77
 
73
78
  clients_to_remove = []
74
-
75
- @sse_clients.each do |client_id, client|
76
- stream = client[:stream]
77
- next if stream.nil? || (stream.respond_to?(:closed?) && stream.closed?)
78
-
79
- stream.write("data: #{json_message}\n\n")
80
- stream.flush if stream.respond_to?(:flush)
81
- rescue Errno::EPIPE, IOError => e
82
- # Broken pipe or IO error - client disconnected
83
- @logger.info("Client #{client_id} disconnected: #{e.message}")
84
- clients_to_remove << client_id
85
- rescue StandardError => e
86
- @logger.error("Error sending message to client #{client_id}: #{e.message}")
87
- # Remove the client if we can't send to it
88
- clients_to_remove << client_id
79
+ @sse_clients_mutex.synchronize do
80
+ @sse_clients.each do |client_id, client|
81
+ stream = client[:stream]
82
+ mutex = client[:mutex]
83
+ next if stream.nil? || (stream.respond_to?(:closed?) && stream.closed?) || mutex.nil?
84
+
85
+ begin
86
+ mutex.synchronize do
87
+ stream.write("data: #{json_message}\n\n")
88
+ stream.flush if stream.respond_to?(:flush)
89
+ end
90
+ rescue Errno::EPIPE, IOError => e
91
+ @logger.info("Client #{client_id} disconnected: #{e.message}")
92
+ clients_to_remove << client_id
93
+ rescue StandardError => e
94
+ @logger.error("Error sending message to client #{client_id}: #{e.message}")
95
+ clients_to_remove << client_id
96
+ end
97
+ end
89
98
  end
90
99
 
91
100
  # Remove disconnected clients outside the loop to avoid modifying the hash during iteration
@@ -93,15 +102,19 @@ module FastMcp
93
102
  end
94
103
 
95
104
  # Register a new SSE client
96
- def register_sse_client(client_id, stream)
97
- @logger.info("Registering SSE client: #{client_id}")
98
- @sse_clients[client_id] = { stream: stream, connected_at: Time.now }
105
+ def register_sse_client(client_id, stream, mutex = nil)
106
+ @sse_clients_mutex.synchronize do
107
+ @logger.info("Registering SSE client: #{client_id}")
108
+ @sse_clients[client_id] = { stream: stream, connected_at: Time.now, mutex: mutex || Mutex.new }
109
+ end
99
110
  end
100
111
 
101
112
  # Unregister an SSE client
102
113
  def unregister_sse_client(client_id)
103
- @logger.info("Unregistering SSE client: #{client_id}")
104
- @sse_clients.delete(client_id)
114
+ @sse_clients_mutex.synchronize do
115
+ @logger.info("Unregistering SSE client: #{client_id}")
116
+ @sse_clients.delete(client_id)
117
+ end
105
118
  end
106
119
 
107
120
  # Rack call method
@@ -196,19 +209,33 @@ module FastMcp
196
209
  # Validate Origin header to prevent DNS rebinding attacks
197
210
  return forbidden_response('Forbidden: Origin validation failed') unless validate_origin(request, env)
198
211
 
212
+ # Get the appropriate server for this request
213
+ request_server = get_server_for_request(request, env)
214
+
215
+ # Store the current transport temporarily if using a filtered server
216
+ if request_server != @server
217
+ original_transport = request_server.transport
218
+ request_server.transport = self
219
+ end
220
+
199
221
  subpath = request.path[@path_prefix.length..]
200
222
  @logger.debug("MCP request subpath: '#{subpath.inspect}'")
201
223
 
202
- case subpath
203
- when "/#{@sse_route}"
204
- handle_sse_request(request, env)
205
- when "/#{@messages_route}"
206
- handle_message_request(request)
207
- else
208
- @logger.error('Received unknown request')
209
- # Return 404 for unknown MCP endpoints
210
- endpoint_not_found_response
211
- end
224
+ result = case subpath
225
+ when "/#{@sse_route}"
226
+ handle_sse_request(request, env)
227
+ when "/#{@messages_route}"
228
+ handle_message_request_with_server(request, request_server)
229
+ else
230
+ @logger.error('Received unknown request')
231
+ # Return 404 for unknown MCP endpoints
232
+ endpoint_not_found_response
233
+ end
234
+
235
+ # Restore original transport if needed
236
+ request_server.transport = original_transport if request_server != @server && original_transport
237
+
238
+ result
212
239
  end
213
240
 
214
241
  def forbidden_response(message)
@@ -265,7 +292,7 @@ module FastMcp
265
292
  else
266
293
  # Fallback for servers that don't support streaming
267
294
  @logger.info('Falling back to default SSE')
268
- [200, headers, [":ok\n\n"]]
295
+ [200, SSE_HEADERS.dup, [":ok\n\n"]]
269
296
  end
270
297
  end
271
298
 
@@ -366,18 +393,24 @@ module FastMcp
366
393
 
367
394
  # Set up the SSE connection
368
395
  def setup_sse_connection(client_id, io, env)
396
+ # Handle for reconnection, if the client_id is already registered we reuse the mutex
397
+ # If not a reconnection, generate a new mutex used in registration
398
+ client = @sse_clients[client_id]
399
+ mutex = client ? client[:mutex] : Mutex.new
369
400
  # Send headers
370
401
  @logger.debug("Sending HTTP headers for SSE connection #{client_id}")
371
- io.write("HTTP/1.1 200 OK\r\n")
372
- SSE_HEADERS.each { |k, v| io.write("#{k}: #{v}\r\n") }
373
- io.write("\r\n")
374
- io.flush
402
+ mutex.synchronize do
403
+ io.write("HTTP/1.1 200 OK\r\n")
404
+ SSE_HEADERS.each { |k, v| io.write("#{k}: #{v}\r\n") }
405
+ io.write("\r\n")
406
+ io.flush
407
+ end
375
408
 
376
- # Register client
377
- register_sse_client(client_id, io)
409
+ # Register client (will overwrite if already present)
410
+ register_sse_client(client_id, io, mutex)
378
411
 
379
412
  # Send an initial comment to keep the connection alive
380
- io.write(": SSE connection established\n\n")
413
+ mutex.synchronize { io.write(": SSE connection established\n\n") }
381
414
 
382
415
  # Extract query parameters from the request
383
416
  query_string = env['QUERY_STRING']
@@ -386,12 +419,14 @@ module FastMcp
386
419
  endpoint = "#{@path_prefix}/#{@messages_route}"
387
420
  endpoint += "?#{query_string}" if query_string
388
421
  @logger.debug("Sending endpoint information to client #{client_id}: #{endpoint}")
389
- io.write("event: endpoint\ndata: #{endpoint}\n\n")
422
+ mutex.synchronize { io.write("event: endpoint\ndata: #{endpoint}\n\n") }
390
423
 
391
424
  # Send a retry directive with a very short reconnect time
392
425
  # This helps browsers reconnect quickly if the connection is lost
393
- io.write("retry: 100\n\n") # 100ms reconnect time
394
- io.flush
426
+ mutex.synchronize do
427
+ io.write("retry: 100\n\n")
428
+ io.flush
429
+ end
395
430
  rescue StandardError => e
396
431
  @logger.error("Error setting up SSE connection for client #{client_id}: #{e.message}")
397
432
  @logger.error(e.backtrace.join("\n")) if e.backtrace
@@ -417,11 +452,10 @@ module FastMcp
417
452
  ping_count = 0
418
453
  ping_interval = 1 # Send a ping every 1 second
419
454
  @running = true
420
-
455
+ mutex = @sse_clients[client_id] && @sse_clients[client_id][:mutex]
421
456
  while @running && !io.closed?
422
457
  begin
423
- ping_count = send_keep_alive_ping(io, client_id, ping_count)
424
-
458
+ mutex.synchronize { ping_count = send_keep_alive_ping(io, client_id, ping_count) }
425
459
  sleep ping_interval
426
460
  rescue Errno::EPIPE, IOError => e
427
461
  # Broken pipe or IO error - client disconnected
@@ -434,7 +468,6 @@ module FastMcp
434
468
  # Send a keep-alive ping and return the updated ping count
435
469
  def send_keep_alive_ping(io, client_id, ping_count)
436
470
  ping_count += 1
437
-
438
471
  # Send a comment before each ping to keep the connection alive
439
472
  io.write(": keep-alive #{ping_count}\n\n")
440
473
  io.flush
@@ -444,7 +477,6 @@ module FastMcp
444
477
  @logger.debug("Sending ping ##{ping_count} to SSE client #{client_id}")
445
478
  send_ping_event(io)
446
479
  end
447
-
448
480
  ping_count
449
481
  end
450
482
 
@@ -462,9 +494,14 @@ module FastMcp
462
494
  # Clean up SSE connection
463
495
  def cleanup_sse_connection(client_id, io)
464
496
  @logger.info("Cleaning up SSE connection for client #{client_id}")
497
+ mutex = @sse_clients[client_id] && @sse_clients[client_id][:mutex]
465
498
  unregister_sse_client(client_id)
466
499
  begin
467
- io.close unless io.closed?
500
+ if mutex
501
+ mutex.synchronize { io.close unless io.closed? }
502
+ else
503
+ io.close unless io.closed?
504
+ end
468
505
  @logger.info("Successfully closed IO for client #{client_id}")
469
506
  rescue StandardError => e
470
507
  @logger.error("Error closing IO for client #{client_id}: #{e.message}")
@@ -484,13 +521,13 @@ module FastMcp
484
521
  [200, SSE_HEADERS, []]
485
522
  end
486
523
 
487
- # Handle message POST request
488
- def handle_message_request(request)
524
+ # Handle message POST request with specific server
525
+ def handle_message_request_with_server(request, server)
489
526
  @logger.debug('Received message request')
490
527
  return method_not_allowed_response unless request.post?
491
528
 
492
529
  begin
493
- process_json_request(request)
530
+ process_json_request_with_server(request, server)
494
531
  rescue JSON::ParserError => e
495
532
  handle_parse_error(e)
496
533
  rescue StandardError => e
@@ -498,14 +535,19 @@ module FastMcp
498
535
  end
499
536
  end
500
537
 
501
- # Process a JSON-RPC request
502
- def process_json_request(request)
538
+ def process_json_request_with_server(request, server)
503
539
  # Parse the request body
504
540
  body = request.body.read
541
+ @logger.debug("Request body: #{body}")
542
+
543
+ # Extract headers that might be relevant
544
+ headers = request.env.select { |k, _v| k.start_with?('HTTP_') }
545
+ .transform_keys { |k| k.sub('HTTP_', '').downcase.tr('_', '-') }
505
546
 
506
- response = process_message(body) || []
507
- @logger.info("Response: #{response}")
547
+ # Let the specific server handle the JSON request directly
548
+ response = server.handle_request(body, headers: headers) || []
508
549
 
550
+ # Return the JSON response
509
551
  [200, { 'Content-Type' => 'application/json' }, response]
510
552
  end
511
553
 
@@ -536,6 +578,50 @@ module FastMcp
536
578
  }
537
579
  )]]
538
580
  end
581
+
582
+ # Get the appropriate server for this request
583
+ def get_server_for_request(request, env)
584
+ # 1. Check for explicit server in env (highest priority)
585
+ if env[SERVER_ENV_KEY]
586
+ @logger.debug("Using server from env[#{SERVER_ENV_KEY}]")
587
+ return env[SERVER_ENV_KEY]
588
+ end
589
+
590
+ # 2. Apply filters if configured
591
+ if @server.contains_filters?
592
+ @logger.debug('Server has filters, creating filtered copy')
593
+ # Cache filtered servers to avoid recreating them
594
+ cache_key = generate_cache_key(request)
595
+
596
+ @filtered_servers_cache[cache_key] ||= @server.create_filtered_copy(request)
597
+ return @filtered_servers_cache[cache_key]
598
+ end
599
+
600
+ # 3. Use the default server
601
+ @logger.debug('Using default server')
602
+ @server
603
+ end
604
+
605
+ # Generate a cache key based on filter-relevant request attributes
606
+ def generate_cache_key(request)
607
+ # Generate a cache key based on filter-relevant request attributes
608
+ # This is a simple example - real implementation would be more sophisticated
609
+ {
610
+ path: request.path,
611
+ params: request.params.sort.to_h,
612
+ headers: extract_relevant_headers(request)
613
+ }.hash
614
+ end
615
+
616
+ # Extract headers that might be relevant for filtering
617
+ def extract_relevant_headers(request)
618
+ relevant_headers = {}
619
+ ['X-User-Role', 'X-API-Version', 'X-Tenant-ID', 'Authorization'].each do |header|
620
+ header_key = "HTTP_#{header.upcase.tr('-', '_')}"
621
+ relevant_headers[header] = request.env[header_key] if request.env[header_key]
622
+ end
623
+ relevant_headers
624
+ end
539
625
  end
540
626
  end
541
627
  end
data/lib/mcp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FastMcp
4
- VERSION = '1.3.2'
4
+ VERSION = '1.5.0'
5
5
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.2
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yorick Jacquin
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-05-09 00:00:00.000000000 Z
11
+ date: 2025-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: addressable
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.8'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.8'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: base64
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -102,6 +116,7 @@ files:
102
116
  - lib/mcp/railtie.rb
103
117
  - lib/mcp/resource.rb
104
118
  - lib/mcp/server.rb
119
+ - lib/mcp/server_filtering.rb
105
120
  - lib/mcp/tool.rb
106
121
  - lib/mcp/transports/authenticated_rack_transport.rb
107
122
  - lib/mcp/transports/base_transport.rb