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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +115 -0
- data/LICENSE +21 -0
- data/README.md +440 -0
- data/lib/fast_mcp.rb +189 -0
- data/lib/generators/fast_mcp/install/install_generator.rb +50 -0
- data/lib/generators/fast_mcp/install/templates/application_resource.rb +5 -0
- data/lib/generators/fast_mcp/install/templates/application_tool.rb +5 -0
- data/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +42 -0
- data/lib/generators/fast_mcp/install/templates/sample_resource.rb +12 -0
- data/lib/generators/fast_mcp/install/templates/sample_tool.rb +23 -0
- data/lib/mcp/logger.rb +32 -0
- data/lib/mcp/railtie.rb +45 -0
- data/lib/mcp/resource.rb +210 -0
- data/lib/mcp/server.rb +499 -0
- data/lib/mcp/server_filtering.rb +80 -0
- data/lib/mcp/tool.rb +867 -0
- data/lib/mcp/transports/authenticated_rack_transport.rb +71 -0
- data/lib/mcp/transports/base_transport.rb +40 -0
- data/lib/mcp/transports/rack_transport.rb +627 -0
- data/lib/mcp/transports/stdio_transport.rb +62 -0
- data/lib/mcp/version.rb +5 -0
- metadata +151 -0
@@ -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
|