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
@@ -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
|
-
@
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|
-
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
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
|
-
@
|
98
|
-
|
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
|
-
@
|
104
|
-
|
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
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
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,
|
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
|
-
|
372
|
-
|
373
|
-
|
374
|
-
|
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
|
-
|
394
|
-
|
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
|
-
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
507
|
-
|
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
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.
|
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-
|
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
|