fast-mcp 0.1.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,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rack_transport'
4
+
5
+ module MCP
6
+ module Transports
7
+ class AuthenticatedRackTransport < RackTransport
8
+ def initialize(server, app, options = {})
9
+ super
10
+
11
+ @auth_token = options[:auth_token]
12
+ @auth_header_name = options[:auth_header_name] || 'Authorization'
13
+ @auth_exempt_paths = options[:auth_exempt_paths] || []
14
+ @auth_enabled = !@auth_token.nil?
15
+ end
16
+
17
+ def call(env)
18
+ request = Rack::Request.new(env)
19
+
20
+ if auth_enabled? && !exempt_from_auth?(request.path)
21
+ auth_header = request.env["HTTP_#{@auth_header_name.upcase.gsub('-', '_')}"]
22
+ token = auth_header&.gsub('Bearer ', '')
23
+
24
+ return unauthorized_response(request) unless valid_token?(token)
25
+ end
26
+
27
+ super
28
+ end
29
+
30
+ private
31
+
32
+ def auth_enabled?
33
+ @auth_enabled
34
+ end
35
+
36
+ def exempt_from_auth?(path)
37
+ @auth_exempt_paths.any? { |exempt_path| path.start_with?(exempt_path) }
38
+ end
39
+
40
+ def valid_token?(token)
41
+ token == @auth_token
42
+ end
43
+
44
+ def unauthorized_response(request)
45
+ body = JSON.generate(
46
+ {
47
+ jsonrpc: '2.0',
48
+ error: {
49
+ code: -32_000,
50
+ message: 'Unauthorized: Invalid or missing authentication token'
51
+ },
52
+ id: extract_request_id(request)
53
+ }
54
+ )
55
+
56
+ [401, { 'Content-Type' => 'application/json' }, [body]]
57
+ end
58
+
59
+ def extract_request_id(request)
60
+ return nil unless request.post?
61
+
62
+ begin
63
+ body = request.body.read
64
+ request.body.rewind
65
+ JSON.parse(body)['id']
66
+ rescue StandardError
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MCP
4
+ module Transports
5
+ # Base class for all MCP transports
6
+ # This defines the interface that all transports must implement
7
+ class BaseTransport
8
+ attr_reader :server, :logger
9
+
10
+ def initialize(server, logger: nil)
11
+ @server = server
12
+ @logger = logger || server.logger
13
+ end
14
+
15
+ # Start the transport
16
+ # This method should be implemented by subclasses
17
+ def start
18
+ raise NotImplementedError, "#{self.class} must implement #start"
19
+ end
20
+
21
+ # Stop the transport
22
+ # This method should be implemented by subclasses
23
+ def stop
24
+ raise NotImplementedError, "#{self.class} must implement #stop"
25
+ end
26
+
27
+ # Send a message to the client
28
+ # This method should be implemented by subclasses
29
+ def send_message(message)
30
+ raise NotImplementedError, "#{self.class} must implement #send_message"
31
+ end
32
+
33
+ # Process an incoming message
34
+ # This is a helper method that can be used by subclasses
35
+ def process_message(message)
36
+ server.handle_json_request(message)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,468 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+ require 'rack'
6
+ require_relative 'base_transport'
7
+
8
+ module MCP
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
13
+ DEFAULT_PATH_PREFIX = '/mcp'
14
+
15
+ attr_reader :app, :path_prefix, :sse_clients
16
+
17
+ def initialize(server, app, options = {})
18
+ super(server, logger: options[:logger])
19
+ @app = app
20
+ @path_prefix = options[:path_prefix] || DEFAULT_PATH_PREFIX
21
+ @sse_clients = {}
22
+ @running = false
23
+ end
24
+
25
+ # Start the transport
26
+ def start
27
+ @logger.info("Starting Rack transport with path prefix: #{@path_prefix}")
28
+ @running = true
29
+ end
30
+
31
+ # Stop the transport
32
+ def stop
33
+ @logger.info('Stopping Rack transport')
34
+ @running = false
35
+
36
+ # Close all SSE connections
37
+ @sse_clients.each_value do |client|
38
+ client[:stream].close if client[:stream].respond_to?(:close) && !client[:stream].closed?
39
+ rescue StandardError => e
40
+ @logger.error("Error closing SSE connection: #{e.message}")
41
+ end
42
+ @sse_clients.clear
43
+ end
44
+
45
+ # Send a message to all connected SSE clients
46
+ def send_message(message)
47
+ json_message = message.is_a?(String) ? message : JSON.generate(message)
48
+ @logger.info("Broadcasting message to #{@sse_clients.size} SSE clients: #{json_message}")
49
+
50
+ clients_to_remove = []
51
+
52
+ @sse_clients.each do |client_id, client|
53
+ stream = client[:stream]
54
+ next if stream.nil? || (stream.respond_to?(:closed?) && stream.closed?)
55
+
56
+ stream.write("data: #{json_message}\n\n")
57
+ stream.flush if stream.respond_to?(:flush)
58
+ rescue Errno::EPIPE, IOError => e
59
+ # Broken pipe or IO error - client disconnected
60
+ @logger.info("Client #{client_id} disconnected: #{e.message}")
61
+ clients_to_remove << client_id
62
+ rescue StandardError => e
63
+ @logger.error("Error sending message to client #{client_id}: #{e.message}")
64
+ # Remove the client if we can't send to it
65
+ clients_to_remove << client_id
66
+ end
67
+
68
+ # Remove disconnected clients outside the loop to avoid modifying the hash during iteration
69
+ clients_to_remove.each { |client_id| unregister_sse_client(client_id) }
70
+ end
71
+
72
+ # Register a new SSE client
73
+ def register_sse_client(client_id, stream)
74
+ @logger.info("Registering SSE client: #{client_id}")
75
+ @sse_clients[client_id] = { stream: stream, connected_at: Time.now }
76
+ end
77
+
78
+ # Unregister an SSE client
79
+ def unregister_sse_client(client_id)
80
+ @logger.info("Unregistering SSE client: #{client_id}")
81
+ @sse_clients.delete(client_id)
82
+ end
83
+
84
+ # Rack call method
85
+ def call(env)
86
+ request = Rack::Request.new(env)
87
+ path = request.path
88
+ @logger.info("Rack request path: #{path}")
89
+
90
+ # Check if the request is for our MCP endpoints
91
+ if path.start_with?(@path_prefix)
92
+ handle_mcp_request(request, env)
93
+ else
94
+ # Pass through to the main application
95
+ @app.call(env)
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ # Handle MCP-specific requests
102
+ def handle_mcp_request(request, env)
103
+ subpath = request.path[@path_prefix.length..]
104
+ @logger.info("MCP request subpath: '#{subpath.inspect}'")
105
+
106
+ case subpath
107
+ when '/sse'
108
+ handle_sse_request(request, env)
109
+ when '/messages'
110
+ @logger.info('Received message request')
111
+ handle_message_request(request)
112
+ else
113
+ @logger.info('Received unknown request')
114
+ # Return 404 for unknown MCP endpoints
115
+ endpoint_not_found_response
116
+ end
117
+ end
118
+
119
+ # Return a 404 endpoint not found response
120
+ def endpoint_not_found_response
121
+ [404, { 'Content-Type' => 'application/json' },
122
+ [JSON.generate(
123
+ {
124
+ jsonrpc: '2.0',
125
+ error: {
126
+ code: -32_601,
127
+ message: 'Endpoint not found'
128
+ },
129
+ id: nil
130
+ }
131
+ )]]
132
+ end
133
+
134
+ # Handle SSE connection request
135
+ def handle_sse_request(request, env)
136
+ # Handle OPTIONS preflight request
137
+ return [200, setup_cors_headers, []] if request.options?
138
+
139
+ return method_not_allowed_response unless request.get?
140
+
141
+ # Set up SSE headers
142
+ headers = setup_sse_headers
143
+
144
+ # Handle streaming based on the framework
145
+ handle_streaming(env, headers)
146
+ end
147
+
148
+ # Handle streaming based on the framework
149
+ def handle_streaming(env, headers)
150
+ @logger.info("Handling streaming for env: #{env['HTTP_USER_AGENT']}")
151
+ if env['rack.hijack']
152
+ # Rack hijacking (e.g., Puma)
153
+ @logger.info('Handling rack hijack SSE')
154
+ handle_rack_hijack_sse(env, headers)
155
+ elsif rails_live_streaming?(env)
156
+ # Rails ActionController::Live
157
+ @logger.info('Handling rails live streaming SSE')
158
+ handle_rails_sse(env, headers)
159
+ else
160
+ # Fallback for servers that don't support streaming
161
+ @logger.info('Falling back to default SSE')
162
+ [200, headers, [":ok\n\n"]]
163
+ end
164
+ end
165
+
166
+ # Check if Rails live streaming is available
167
+ def rails_live_streaming?(env)
168
+ defined?(ActionController::Live) &&
169
+ env['action_controller.instance'].respond_to?(:response) &&
170
+ env['action_controller.instance'].response.respond_to?(:stream)
171
+ end
172
+
173
+ # Set up headers for SSE connection
174
+ def setup_sse_headers
175
+ {
176
+ 'Content-Type' => 'text/event-stream',
177
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
178
+ 'Connection' => 'keep-alive',
179
+ 'X-Accel-Buffering' => 'no', # For Nginx
180
+ 'Access-Control-Allow-Origin' => '*', # Allow CORS
181
+ 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
182
+ 'Access-Control-Allow-Headers' => 'Content-Type',
183
+ 'Access-Control-Max-Age' => '86400', # 24 hours
184
+ 'Keep-Alive' => 'timeout=600', # 10 minutes timeout
185
+ 'Pragma' => 'no-cache',
186
+ 'Expires' => '0'
187
+ }
188
+ end
189
+
190
+ # Set up CORS headers for preflight requests
191
+ def setup_cors_headers
192
+ {
193
+ 'Access-Control-Allow-Origin' => '*',
194
+ 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
195
+ 'Access-Control-Allow-Headers' => 'Content-Type',
196
+ 'Access-Control-Max-Age' => '86400', # 24 hours
197
+ 'Content-Type' => 'text/plain'
198
+ }
199
+ end
200
+
201
+ # Extract client ID from request or generate a new one
202
+ def extract_client_id(env)
203
+ request = Rack::Request.new(env)
204
+
205
+ # Check various places for client ID
206
+ client_id = request.params['client_id']
207
+ client_id ||= env['HTTP_LAST_EVENT_ID']
208
+ client_id ||= env['HTTP_X_CLIENT_ID']
209
+
210
+ # Get browser information
211
+ user_agent = env['HTTP_USER_AGENT'] || ''
212
+ browser_type = detect_browser_type(user_agent)
213
+ @logger.info("Client connection from: #{user_agent} (#{browser_type})")
214
+
215
+ # Handle MCP inspector with fixed client ID
216
+ @logger.info("MCP Inspector detected, using fixed client ID: #{client_id}") if mcp_inspector?(user_agent, env)
217
+
218
+ # Handle reconnection
219
+ if client_id && @sse_clients.key?(client_id)
220
+ handle_client_reconnection(client_id, browser_type)
221
+ else
222
+ # Generate a new client ID if none was provided
223
+ client_id ||= SecureRandom.uuid
224
+ @logger.info("New client connection: #{client_id} (#{browser_type})")
225
+ end
226
+
227
+ client_id
228
+ end
229
+
230
+ # Detect browser type from user agent
231
+ def detect_browser_type(user_agent)
232
+ is_chrome = user_agent.include?('Chrome/')
233
+ is_safari = user_agent.include?('Safari/') && !user_agent.include?('Chrome/')
234
+ is_firefox = user_agent.include?('Firefox/')
235
+ is_node = user_agent.include?('Node.js') || user_agent.include?('node-fetch')
236
+
237
+ if is_chrome
238
+ 'Chrome'
239
+ elsif is_safari
240
+ 'Safari'
241
+ elsif is_firefox
242
+ 'Firefox'
243
+ elsif is_node
244
+ 'Node.js'
245
+ else
246
+ 'Other browser'
247
+ end
248
+ end
249
+
250
+ # Check if client is MCP inspector
251
+ def mcp_inspector?(user_agent, env)
252
+ user_agent.include?('mcp-inspector') || (env['mcp.client_name'] == 'mcp-inspector')
253
+ end
254
+
255
+ # Handle client reconnection
256
+ def handle_client_reconnection(client_id, browser_type)
257
+ @logger.info("Client #{client_id} is reconnecting (#{browser_type})")
258
+ old_client = @sse_clients[client_id]
259
+ begin
260
+ old_client[:stream].close if old_client[:stream].respond_to?(:close) && !old_client[:stream].closed?
261
+ rescue StandardError => e
262
+ @logger.error("Error closing old connection for client #{client_id}: #{e.message}")
263
+ end
264
+ unregister_sse_client(client_id)
265
+
266
+ # Small delay to ensure the old connection is fully closed
267
+ sleep 0.1
268
+ end
269
+
270
+ # Handle SSE with Rack hijacking (e.g., Puma)
271
+ def handle_rack_hijack_sse(env, headers)
272
+ client_id = extract_client_id(env)
273
+ @logger.info("Setting up Rack hijack SSE connection for client #{client_id}")
274
+
275
+ env['rack.hijack'].call
276
+ io = env['rack.hijack_io']
277
+ @logger.info("Obtained hijack IO for client #{client_id}")
278
+
279
+ setup_sse_connection(client_id, io, headers)
280
+ start_keep_alive_thread(client_id, io)
281
+
282
+ # Return async response
283
+ [-1, {}, []]
284
+ end
285
+
286
+ # Set up the SSE connection
287
+ def setup_sse_connection(client_id, io, headers)
288
+ # Send headers
289
+ @logger.info("Sending HTTP headers for SSE connection #{client_id}")
290
+ io.write("HTTP/1.1 200 OK\r\n")
291
+ headers.each { |k, v| io.write("#{k}: #{v}\r\n") }
292
+ io.write("\r\n")
293
+ io.flush
294
+
295
+ # Register client
296
+ register_sse_client(client_id, io)
297
+
298
+ # Send an initial comment to keep the connection alive
299
+ io.write(": SSE connection established\n\n")
300
+
301
+ # Send endpoint information as the first message
302
+ endpoint = "#{@path_prefix}/messages"
303
+ @logger.info("Sending endpoint information to client #{client_id}: #{endpoint}")
304
+ io.write("event: endpoint\ndata: #{endpoint}\n\n")
305
+
306
+ # Send a retry directive with a very short reconnect time
307
+ # This helps browsers reconnect quickly if the connection is lost
308
+ io.write("retry: 100\n\n") # 100ms reconnect time
309
+ io.flush
310
+ rescue StandardError => e
311
+ @logger.error("Error setting up SSE connection for client #{client_id}: #{e.message}")
312
+ @logger.error(e.backtrace.join("\n")) if e.backtrace
313
+ raise
314
+ end
315
+
316
+ # Start a keep-alive thread for SSE connection
317
+ def start_keep_alive_thread(client_id, io)
318
+ @logger.info("Starting keep-alive thread for client #{client_id}")
319
+ Thread.new do
320
+ keep_alive_loop(io, client_id)
321
+ rescue StandardError => e
322
+ @logger.error("Error in SSE keep-alive for client #{client_id}: #{e.message}")
323
+ @logger.error(e.backtrace.join("\n")) if e.backtrace
324
+ ensure
325
+ cleanup_sse_connection(client_id, io)
326
+ end
327
+ end
328
+
329
+ # Run the keep-alive loop
330
+ def keep_alive_loop(io, client_id)
331
+ @logger.info("Starting keep-alive loop for SSE connection #{client_id}")
332
+ ping_count = 0
333
+ ping_interval = 1 # Send a ping every 1 second
334
+ max_ping_count = 30 # Reset connection after 30 pings (about 30 seconds)
335
+
336
+ while @running && !io.closed?
337
+ begin
338
+ ping_count = send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
339
+ break if ping_count >= max_ping_count
340
+
341
+ sleep ping_interval
342
+ rescue Errno::EPIPE, IOError => e
343
+ # Broken pipe or IO error - client disconnected
344
+ @logger.error("SSE connection error for client #{client_id}: #{e.message}")
345
+ break
346
+ end
347
+ end
348
+ end
349
+
350
+ # Send a keep-alive ping and return the updated ping count
351
+ def send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
352
+ ping_count += 1
353
+
354
+ # Send a comment before each ping to keep the connection alive
355
+ io.write(": keep-alive #{ping_count}\n\n")
356
+ io.flush
357
+
358
+ # Only send actual ping events every 5 counts to reduce overhead
359
+ if (ping_count % 5).zero?
360
+ @logger.info("Sending ping ##{ping_count} to SSE client #{client_id}")
361
+ send_ping_event(io)
362
+ end
363
+
364
+ # If we've reached the max ping count, force a reconnection
365
+ if ping_count >= max_ping_count
366
+ @logger.info("Reached max ping count (#{max_ping_count}) for client #{client_id}, forcing reconnection")
367
+ send_reconnect_event(io)
368
+ end
369
+
370
+ ping_count
371
+ end
372
+
373
+ # Send a ping event
374
+ def send_ping_event(io)
375
+ ping_message = {
376
+ jsonrpc: '2.0',
377
+ method: 'ping',
378
+ id: SecureRandom.uuid
379
+ }
380
+ io.write("event: ping\ndata: #{JSON.generate(ping_message)}\n\n")
381
+ io.flush
382
+ end
383
+
384
+ # Send a reconnect event
385
+ def send_reconnect_event(io)
386
+ io.write("event: reconnect\ndata: {\"reason\":\"timeout prevention\"}\n\n")
387
+ io.flush
388
+ end
389
+
390
+ # Clean up SSE connection
391
+ def cleanup_sse_connection(client_id, io)
392
+ @logger.info("Cleaning up SSE connection for client #{client_id}")
393
+ unregister_sse_client(client_id)
394
+ begin
395
+ io.close unless io.closed?
396
+ @logger.info("Successfully closed IO for client #{client_id}")
397
+ rescue StandardError => e
398
+ @logger.error("Error closing IO for client #{client_id}: #{e.message}")
399
+ end
400
+ end
401
+
402
+ # Handle SSE with Rails ActionController::Live
403
+ def handle_rails_sse(env, headers)
404
+ client_id = extract_client_id(env)
405
+ controller = env['action_controller.instance']
406
+ stream = controller.response.stream
407
+
408
+ # Register client
409
+ register_sse_client(client_id, stream)
410
+
411
+ # The controller will handle the streaming
412
+ [200, headers, []]
413
+ end
414
+
415
+ # Handle message POST request
416
+ def handle_message_request(request)
417
+ @logger.info('Received message request')
418
+ return method_not_allowed_response unless request.post?
419
+
420
+ begin
421
+ process_json_request(request)
422
+ rescue JSON::ParserError => e
423
+ handle_parse_error(e)
424
+ rescue StandardError => e
425
+ handle_internal_error(e)
426
+ end
427
+ end
428
+
429
+ # Process a JSON-RPC request
430
+ def process_json_request(request)
431
+ # Parse the request body
432
+ body = request.body.read
433
+
434
+ response = process_message(body)
435
+ @logger.info("Response: #{response}")
436
+ [200, { 'Content-Type' => 'application/json' }, [response]]
437
+ end
438
+
439
+ # Return a method not allowed error response
440
+ def method_not_allowed_response
441
+ json_rpc_error_response(405, -32_601, 'Method not allowed')
442
+ end
443
+
444
+ # Handle JSON parse errors
445
+ def handle_parse_error(error)
446
+ @logger.error("Invalid JSON in request: #{error.message}")
447
+ json_rpc_error_response(400, -32_700, 'Parse error: Invalid JSON')
448
+ end
449
+
450
+ # Handle internal server errors
451
+ def handle_internal_error(error)
452
+ @logger.error("Error processing message: #{error.message}")
453
+ json_rpc_error_response(500, -32_603, "Internal error: #{error.message}")
454
+ end
455
+
456
+ def json_rpc_error_response(http_status, code, message, id = nil)
457
+ [http_status, { 'Content-Type' => 'application/json' },
458
+ [JSON.generate(
459
+ {
460
+ jsonrpc: '2.0',
461
+ error: { code: code, message: message },
462
+ id: id
463
+ }
464
+ )]]
465
+ end
466
+ end
467
+ end
468
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base_transport'
4
+
5
+ module MCP
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,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Version information
4
+ module FastMCP
5
+ VERSION = '0.1.0'
6
+ end