fast-mcp-annotations 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,627 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'rack'
6
+ require_relative 'base_transport'
7
+
8
+ module FastMcp
9
+ module Transports
10
+ # Rack middleware transport for MCP
11
+ # This transport can be mounted in any Rack-compatible web framework
12
+ class RackTransport < BaseTransport # rubocop:disable Metrics/ClassLength
13
+ DEFAULT_PATH_PREFIX = '/mcp'
14
+ DEFAULT_ALLOWED_ORIGINS = ['localhost', '127.0.0.1', '[::1]'].freeze
15
+ DEFAULT_ALLOWED_IPS = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].freeze
16
+ SERVER_ENV_KEY = 'fast_mcp.server'
17
+
18
+ SSE_HEADERS = {
19
+ 'Content-Type' => 'text/event-stream',
20
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
21
+ 'Connection' => 'keep-alive',
22
+ 'X-Accel-Buffering' => 'no', # For Nginx
23
+ 'Access-Control-Allow-Origin' => '*', # Allow CORS
24
+ 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
25
+ 'Access-Control-Allow-Headers' => 'Content-Type',
26
+ 'Access-Control-Max-Age' => '86400', # 24 hours
27
+ 'Keep-Alive' => 'timeout=600', # 10 minutes timeout
28
+ 'Pragma' => 'no-cache',
29
+ 'Expires' => '0'
30
+ }.freeze
31
+
32
+ attr_reader :app, :path_prefix, :sse_clients, :messages_route, :sse_route, :allowed_origins, :localhost_only,
33
+ :allowed_ips
34
+
35
+ def initialize(app, server, options = {}, &_block)
36
+ super(server, logger: options[:logger])
37
+ @app = app
38
+ @path_prefix = options[:path_prefix] || DEFAULT_PATH_PREFIX
39
+ @messages_route = options[:messages_route] || 'messages'
40
+ @sse_route = options[:sse_route] || 'sse'
41
+ @allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
42
+ @localhost_only = options.fetch(:localhost_only, true) # Default to localhost-only mode
43
+ @allowed_ips = options[:allowed_ips] || DEFAULT_ALLOWED_IPS
44
+ @sse_clients = Concurrent::Hash.new
45
+ @sse_clients_mutex = Mutex.new
46
+ @running = false
47
+ @filtered_servers_cache = {}
48
+ end
49
+
50
+ # Start the transport
51
+ def start
52
+ @logger.debug("Starting Rack transport with path prefix: #{@path_prefix}")
53
+ @logger.debug("DNS rebinding protection enabled. Allowed origins: #{allowed_origins.join(', ')}")
54
+ @running = true
55
+ end
56
+
57
+ # Stop the transport
58
+ def stop
59
+ @logger.debug('Stopping Rack transport')
60
+ @running = false
61
+
62
+ # Close all SSE connections
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
70
+ end
71
+ end
72
+
73
+ # Send a message to all connected SSE clients
74
+ def send_message(message)
75
+ json_message = message.is_a?(String) ? message : JSON.generate(message)
76
+ @logger.debug("Broadcasting message to #{@sse_clients.size} SSE clients: #{json_message}")
77
+
78
+ clients_to_remove = []
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
98
+ end
99
+
100
+ # Remove disconnected clients outside the loop to avoid modifying the hash during iteration
101
+ clients_to_remove.each { |client_id| unregister_sse_client(client_id) }
102
+ end
103
+
104
+ # Register a new SSE client
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
110
+ end
111
+
112
+ # Unregister an SSE client
113
+ def unregister_sse_client(client_id)
114
+ @sse_clients_mutex.synchronize do
115
+ @logger.info("Unregistering SSE client: #{client_id}")
116
+ @sse_clients.delete(client_id)
117
+ end
118
+ end
119
+
120
+ # Rack call method
121
+ def call(env)
122
+ request = Rack::Request.new(env)
123
+ path = request.path
124
+ @logger.debug("Rack request path: #{path}")
125
+
126
+ # Check if the request is for our MCP endpoints
127
+ if path.start_with?(@path_prefix)
128
+ @logger.debug('Setting server transport to RackTransport')
129
+ @server.transport = self
130
+ handle_mcp_request(request, env)
131
+ else
132
+ # Pass through to the main application
133
+ @app.call(env)
134
+ end
135
+ end
136
+
137
+ private
138
+
139
+ def validate_client_ip(request)
140
+ client_ip = request.ip
141
+
142
+ # Check if we're in localhost-only mode
143
+ if @localhost_only && !@allowed_ips.include?(client_ip)
144
+ @logger.warn("Blocked connection from non-localhost IP: #{client_ip}")
145
+ return false
146
+ end
147
+
148
+ true
149
+ end
150
+
151
+ # Validate the Origin header to prevent DNS rebinding attacks
152
+ def validate_origin(request, env)
153
+ origin = env['HTTP_ORIGIN']
154
+
155
+ # If no origin header is present, check the referer or host
156
+ origin = env['HTTP_REFERER'] || request.host if origin.nil? || origin.empty?
157
+
158
+ # Extract hostname from the origin
159
+ hostname = extract_hostname(origin)
160
+
161
+ # If we have a hostname and allowed_origins is not empty
162
+ if hostname && !allowed_origins.empty?
163
+ @logger.debug("Validating origin: #{hostname}")
164
+
165
+ # Check if the hostname matches any allowed origin
166
+ is_allowed = allowed_origins.any? do |allowed|
167
+ if allowed.is_a?(Regexp)
168
+ hostname.match?(allowed)
169
+ else
170
+ hostname == allowed
171
+ end
172
+ end
173
+
174
+ unless is_allowed
175
+ @logger.warn("Blocked request with origin: #{hostname}")
176
+ return false
177
+ end
178
+ end
179
+
180
+ true
181
+ end
182
+
183
+ # Extract hostname from a URL
184
+ def extract_hostname(url)
185
+ return nil if url.nil? || url.empty?
186
+
187
+ begin
188
+ # Check if the URL has a scheme, if not, add http:// as a prefix
189
+ has_scheme = url.match?(%r{^[a-zA-Z][a-zA-Z0-9+.-]*://})
190
+ parsing_url = has_scheme ? url : "http://#{url}"
191
+
192
+ uri = URI.parse(parsing_url)
193
+
194
+ # Return nil for invalid URLs where host is empty
195
+ return nil if uri.host.nil? || uri.host.empty?
196
+
197
+ uri.host
198
+ rescue URI::InvalidURIError
199
+ # If standard parsing fails, try to extract host with a regex for host:port format
200
+ url.split(':').first if url.match?(%r{^([^:/]+)(:\d+)?$})
201
+ end
202
+ end
203
+
204
+ # Handle MCP-specific requests
205
+ def handle_mcp_request(request, env)
206
+ # Validate client IP to ensure it's connecting from allowed sources
207
+ return forbidden_response('Forbidden: Remote IP not allowed') unless validate_client_ip(request)
208
+
209
+ # Validate Origin header to prevent DNS rebinding attacks
210
+ return forbidden_response('Forbidden: Origin validation failed') unless validate_origin(request, env)
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
+
221
+ subpath = request.path[@path_prefix.length..]
222
+ @logger.debug("MCP request subpath: '#{subpath.inspect}'")
223
+
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
239
+ end
240
+
241
+ def forbidden_response(message)
242
+ [403, { 'Content-Type' => 'application/json' },
243
+ [JSON.generate(
244
+ {
245
+ jsonrpc: '2.0',
246
+ error: {
247
+ code: -32_600,
248
+ message: message
249
+ },
250
+ id: nil
251
+ }
252
+ )]]
253
+ end
254
+
255
+ # Return a 404 endpoint not found response
256
+ def endpoint_not_found_response
257
+ [404, { 'Content-Type' => 'application/json' },
258
+ [JSON.generate(
259
+ {
260
+ jsonrpc: '2.0',
261
+ error: {
262
+ code: -32_601,
263
+ message: 'Endpoint not found'
264
+ },
265
+ id: nil
266
+ }
267
+ )]]
268
+ end
269
+
270
+ # Handle SSE connection request
271
+ def handle_sse_request(request, env)
272
+ # Handle OPTIONS preflight request
273
+ return [200, setup_cors_headers, []] if request.options?
274
+
275
+ return method_not_allowed_response unless request.get?
276
+
277
+ # Handle streaming based on the framework
278
+ handle_streaming(env)
279
+ end
280
+
281
+ # Handle streaming based on the framework
282
+ def handle_streaming(env)
283
+ @logger.info("Handling streaming for env: #{env['HTTP_USER_AGENT']}")
284
+ if env['rack.hijack']
285
+ # Rack hijacking (e.g., Puma)
286
+ @logger.info('Handling rack hijack SSE')
287
+ handle_rack_hijack_sse(env)
288
+ elsif rails_live_streaming?(env)
289
+ # Rails ActionController::Live
290
+ @logger.info('Handling rails live streaming SSE')
291
+ handle_rails_sse(env)
292
+ else
293
+ # Fallback for servers that don't support streaming
294
+ @logger.info('Falling back to default SSE')
295
+ [200, SSE_HEADERS.dup, [":ok\n\n"]]
296
+ end
297
+ end
298
+
299
+ # Check if Rails live streaming is available
300
+ def rails_live_streaming?(env)
301
+ defined?(ActionController::Live) &&
302
+ env['action_controller.instance'].respond_to?(:response) &&
303
+ env['action_controller.instance'].response.respond_to?(:stream)
304
+ end
305
+
306
+ # Set up CORS headers for preflight requests
307
+ def setup_cors_headers
308
+ {
309
+ 'Access-Control-Allow-Origin' => '*',
310
+ 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
311
+ 'Access-Control-Allow-Headers' => 'Content-Type',
312
+ 'Access-Control-Max-Age' => '86400', # 24 hours
313
+ 'Content-Type' => 'text/plain'
314
+ }
315
+ end
316
+
317
+ # Extract client ID from request or generate a new one
318
+ def extract_client_id(env)
319
+ request = Rack::Request.new(env)
320
+
321
+ # Check various places for client ID
322
+ client_id = request.params['client_id']
323
+ client_id ||= env['HTTP_LAST_EVENT_ID']
324
+ client_id ||= env['HTTP_X_CLIENT_ID']
325
+
326
+ # Get browser information
327
+ user_agent = env['HTTP_USER_AGENT'] || ''
328
+ browser_type = detect_browser_type(user_agent)
329
+ @logger.info("Client connection from: #{user_agent} (#{browser_type})")
330
+
331
+ # Handle reconnection
332
+ if client_id && @sse_clients.key?(client_id)
333
+ handle_client_reconnection(client_id, browser_type)
334
+ else
335
+ # Generate a new client ID if none was provided
336
+ client_id ||= SecureRandom.uuid
337
+ @logger.info("New client connection: #{client_id} (#{browser_type})")
338
+ end
339
+
340
+ client_id
341
+ end
342
+
343
+ # Detect browser type from user agent
344
+ def detect_browser_type(user_agent)
345
+ is_chrome = user_agent.include?('Chrome/')
346
+ is_safari = user_agent.include?('Safari/') && !user_agent.include?('Chrome/')
347
+ is_firefox = user_agent.include?('Firefox/')
348
+ is_node = user_agent.include?('Node.js') || user_agent.include?('node-fetch')
349
+
350
+ if is_chrome
351
+ 'Chrome'
352
+ elsif is_safari
353
+ 'Safari'
354
+ elsif is_firefox
355
+ 'Firefox'
356
+ elsif is_node
357
+ 'Node.js'
358
+ else
359
+ 'Other browser'
360
+ end
361
+ end
362
+
363
+ # Handle client reconnection
364
+ def handle_client_reconnection(client_id, browser_type)
365
+ @logger.info("Client #{client_id} is reconnecting (#{browser_type})")
366
+ old_client = @sse_clients[client_id]
367
+ begin
368
+ old_client[:stream].close if old_client[:stream].respond_to?(:close) && !old_client[:stream].closed?
369
+ rescue StandardError => e
370
+ @logger.error("Error closing old connection for client #{client_id}: #{e.message}")
371
+ end
372
+ unregister_sse_client(client_id)
373
+
374
+ # Small delay to ensure the old connection is fully closed
375
+ sleep 0.1
376
+ end
377
+
378
+ # Handle SSE with Rack hijacking (e.g., Puma)
379
+ def handle_rack_hijack_sse(env)
380
+ client_id = extract_client_id(env)
381
+ @logger.debug("Setting up Rack hijack SSE connection for client #{client_id}")
382
+
383
+ env['rack.hijack'].call
384
+ io = env['rack.hijack_io']
385
+ @logger.debug("Obtained hijack IO for client #{client_id}")
386
+
387
+ setup_sse_connection(client_id, io, env)
388
+ start_keep_alive_thread(client_id, io)
389
+
390
+ # Return async response
391
+ [-1, {}, []]
392
+ end
393
+
394
+ # Set up the SSE connection
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
400
+ # Send headers
401
+ @logger.debug("Sending HTTP headers for SSE connection #{client_id}")
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
408
+
409
+ # Register client (will overwrite if already present)
410
+ register_sse_client(client_id, io, mutex)
411
+
412
+ # Send an initial comment to keep the connection alive
413
+ mutex.synchronize { io.write(": SSE connection established\n\n") }
414
+
415
+ # Extract query parameters from the request
416
+ query_string = env['QUERY_STRING']
417
+
418
+ # Send endpoint information as the first message with query parameters
419
+ endpoint = "#{@path_prefix}/#{@messages_route}"
420
+ endpoint += "?#{query_string}" if query_string
421
+ @logger.debug("Sending endpoint information to client #{client_id}: #{endpoint}")
422
+ mutex.synchronize { io.write("event: endpoint\ndata: #{endpoint}\n\n") }
423
+
424
+ # Send a retry directive with a very short reconnect time
425
+ # This helps browsers reconnect quickly if the connection is lost
426
+ mutex.synchronize do
427
+ io.write("retry: 100\n\n")
428
+ io.flush
429
+ end
430
+ rescue StandardError => e
431
+ @logger.error("Error setting up SSE connection for client #{client_id}: #{e.message}")
432
+ @logger.error(e.backtrace.join("\n")) if e.backtrace
433
+ raise
434
+ end
435
+
436
+ # Start a keep-alive thread for SSE connection
437
+ def start_keep_alive_thread(client_id, io)
438
+ @logger.info("Starting keep-alive thread for client #{client_id}")
439
+ Thread.new do
440
+ keep_alive_loop(io, client_id)
441
+ rescue StandardError => e
442
+ @logger.error("Error in SSE keep-alive for client #{client_id}: #{e.message}")
443
+ @logger.error(e.backtrace.join("\n")) if e.backtrace
444
+ ensure
445
+ cleanup_sse_connection(client_id, io)
446
+ end
447
+ end
448
+
449
+ # Run the keep-alive loop
450
+ def keep_alive_loop(io, client_id)
451
+ @logger.info("Starting keep-alive loop for SSE connection #{client_id}")
452
+ ping_count = 0
453
+ ping_interval = 1 # Send a ping every 1 second
454
+ @running = true
455
+ mutex = @sse_clients[client_id] && @sse_clients[client_id][:mutex]
456
+ while @running && !io.closed?
457
+ begin
458
+ mutex.synchronize { ping_count = send_keep_alive_ping(io, client_id, ping_count) }
459
+ sleep ping_interval
460
+ rescue Errno::EPIPE, IOError => e
461
+ # Broken pipe or IO error - client disconnected
462
+ @logger.error("SSE connection error for client #{client_id}: #{e.message}")
463
+ break
464
+ end
465
+ end
466
+ end
467
+
468
+ # Send a keep-alive ping and return the updated ping count
469
+ def send_keep_alive_ping(io, client_id, ping_count)
470
+ ping_count += 1
471
+ # Send a comment before each ping to keep the connection alive
472
+ io.write(": keep-alive #{ping_count}\n\n")
473
+ io.flush
474
+
475
+ # Only send actual ping events every 5 counts to reduce overhead
476
+ if (ping_count % 5).zero?
477
+ @logger.debug("Sending ping ##{ping_count} to SSE client #{client_id}")
478
+ send_ping_event(io)
479
+ end
480
+ ping_count
481
+ end
482
+
483
+ # Send a ping event
484
+ def send_ping_event(io)
485
+ ping_message = {
486
+ jsonrpc: '2.0',
487
+ method: 'ping',
488
+ id: rand(1_000_000)
489
+ }
490
+ io.write("event: message\ndata: #{JSON.generate(ping_message)}\n\n")
491
+ io.flush
492
+ end
493
+
494
+ # Clean up SSE connection
495
+ def cleanup_sse_connection(client_id, io)
496
+ @logger.info("Cleaning up SSE connection for client #{client_id}")
497
+ mutex = @sse_clients[client_id] && @sse_clients[client_id][:mutex]
498
+ unregister_sse_client(client_id)
499
+ begin
500
+ if mutex
501
+ mutex.synchronize { io.close unless io.closed? }
502
+ else
503
+ io.close unless io.closed?
504
+ end
505
+ @logger.info("Successfully closed IO for client #{client_id}")
506
+ rescue StandardError => e
507
+ @logger.error("Error closing IO for client #{client_id}: #{e.message}")
508
+ end
509
+ end
510
+
511
+ # Handle SSE with Rails ActionController::Live
512
+ def handle_rails_sse(env)
513
+ client_id = extract_client_id(env)
514
+ controller = env['action_controller.instance']
515
+ stream = controller.response.stream
516
+
517
+ # Register client
518
+ register_sse_client(client_id, stream)
519
+
520
+ # The controller will handle the streaming
521
+ [200, SSE_HEADERS, []]
522
+ end
523
+
524
+ # Handle message POST request with specific server
525
+ def handle_message_request_with_server(request, server)
526
+ @logger.debug('Received message request')
527
+ return method_not_allowed_response unless request.post?
528
+
529
+ begin
530
+ process_json_request_with_server(request, server)
531
+ rescue JSON::ParserError => e
532
+ handle_parse_error(e)
533
+ rescue StandardError => e
534
+ handle_internal_error(e)
535
+ end
536
+ end
537
+
538
+ def process_json_request_with_server(request, server)
539
+ # Parse the request body
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('_', '-') }
546
+
547
+ # Let the specific server handle the JSON request directly
548
+ response = server.handle_request(body, headers: headers) || []
549
+
550
+ # Return the JSON response
551
+ [200, { 'Content-Type' => 'application/json' }, response]
552
+ end
553
+
554
+ # Return a method not allowed error response
555
+ def method_not_allowed_response
556
+ json_rpc_error_response(405, -32_601, 'Method not allowed')
557
+ end
558
+
559
+ # Handle JSON parse errors
560
+ def handle_parse_error(error)
561
+ @logger.error("Invalid JSON in request: #{error.message}")
562
+ json_rpc_error_response(400, -32_700, 'Parse error: Invalid JSON')
563
+ end
564
+
565
+ # Handle internal server errors
566
+ def handle_internal_error(error)
567
+ @logger.error("Error processing message: #{error.message}")
568
+ json_rpc_error_response(500, -32_603, "Internal error: #{error.message}")
569
+ end
570
+
571
+ def json_rpc_error_response(http_status, code, message, id = nil)
572
+ [http_status, { 'Content-Type' => 'application/json' },
573
+ [JSON.generate(
574
+ {
575
+ jsonrpc: '2.0',
576
+ error: { code: code, message: message },
577
+ id: id
578
+ }
579
+ )]]
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
625
+ end
626
+ end
627
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_transport'
4
+
5
+ module FastMcp
6
+ module Transports
7
+ # STDIO transport for MCP
8
+ # This transport uses standard input/output for communication
9
+ class StdioTransport < BaseTransport
10
+ def initialize(server, logger: nil)
11
+ super
12
+ @running = false
13
+ end
14
+
15
+ # Start the transport
16
+ def start
17
+ @logger.info('Starting STDIO transport')
18
+ @running = true
19
+
20
+ # Process input from stdin
21
+ while @running && (line = $stdin.gets)
22
+ begin
23
+ process_message(line.strip)
24
+ rescue StandardError => e
25
+ @logger.error("Error processing message: #{e.message}")
26
+ @logger.error(e.backtrace.join("\n"))
27
+ send_error(-32_000, "Internal error: #{e.message}")
28
+ end
29
+ end
30
+ end
31
+
32
+ # Stop the transport
33
+ def stop
34
+ @logger.info('Stopping STDIO transport')
35
+ @running = false
36
+ end
37
+
38
+ # Send a message to the client
39
+ def send_message(message)
40
+ json_message = message.is_a?(String) ? message : JSON.generate(message)
41
+
42
+ $stdout.puts(json_message)
43
+ $stdout.flush
44
+ end
45
+
46
+ private
47
+
48
+ # Send a JSON-RPC error response
49
+ def send_error(code, message, id = nil)
50
+ response = {
51
+ jsonrpc: '2.0',
52
+ error: {
53
+ code: code,
54
+ message: message
55
+ },
56
+ id: id
57
+ }
58
+ send_message(response)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FastMcp
4
+ VERSION = '1.5.0'
5
+ end