fast-mcp 1.1.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dde8305abcf47ffc314fc95df26467f737957e1b97eecae8c2c4a88b34c76017
4
- data.tar.gz: 22715faf289cd3f45090480f59585220c3bb904b2a15c9669c5b93700a85e104
3
+ metadata.gz: 688719b076ec2db4860e3e8a0d40d5417b485cdafbaebb777975d680600c1b9b
4
+ data.tar.gz: e367624636eecd848a48fd89eff2fd0860b7b1468287d63da77e529d76e96f6f
5
5
  SHA512:
6
- metadata.gz: eacade9a05c17a5032bbe2ed13e68bf094e7d018f272163a70f2457971c60ed79672488ab177fe3926dc9aa51cbf738ed2e3ba70bd9ab4c309599e593659ab97
7
- data.tar.gz: 390cb3617e29219da28df5c49ff7da06c36b09d341068b5bc4c20883b64dd61f33c67e0542e03f368bf686129f1338ea5af3157bac28439c824520095250adfd
6
+ metadata.gz: c861b5abf1a982e435b5954075ebaeeece64559a79bae67e36a7d91313a05eba45d5bd61924b87eddc2f6a12f63e69187e3821c4856329e0b2f48b68834bec0f
7
+ data.tar.gz: 53b8d45da7985600f6b4bb8c131159c769c7458ad2b94471cbfbcd8497154f21c50061fba8a6ac3768bb54c21375035d3fd9f18bc79c008504e54e087017553e
data/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.3.0] - 2025-04-28
9
+ ### Added
10
+ - Added automatic forwarding of query params from to the messages endpoint [@yjacquin](https://github.com/yjacquin/fast-mcp/commit/011d968ac982d0b0084f7753dcac5789f66339ee)
11
+
12
+ ### Fixed
13
+ - Declare rack as an explicit dependency [#49 @subelsky](https://github.com/yjacquin/fast-mcp/pull/49)
14
+ - Fix notifications/initialized response [#51 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/51)
15
+
16
+ ## [1.2.0] - 2025-04-21
17
+ ### Added
18
+ - Security enhancement: Bing only to localhost by default [#44 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/44)
19
+ - Prevent AuthenticatedRackMiddleware from blocking other rails routes[#35 @JulianPasquale](https://github.com/yjacquin/fast-mcp/pull/35)
20
+ - Stop Forcing reconnections after 30 pings [#42 @zoedsoupe](https://github.com/yjacquin/fast-mcp/pull/42)
21
+
22
+
8
23
  ## [1.1.0] - 2025-04-13
9
24
  ### Added
10
25
  - Security enhancement: Added DNS rebinding protection by validating Origin headers [#32 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/32/files)
@@ -60,5 +75,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
60
75
  - Resource management with subscription capabilities
61
76
  - Binary resource support
62
77
  - Examples with STDIO Transport, HTTP & SSE, Rack app
63
- - Initialize lifecycle with capabilities
78
+ - Initialize lifecycle with capabilities
64
79
  - Comprehensive test suite with RSpec
data/README.md CHANGED
@@ -103,6 +103,9 @@ FastMcp.mount_in_rails(
103
103
  sse_route: 'sse', # This is the default route for the SSE endpoint
104
104
  # Add allowed origins below, it defaults to Rails.application.config.hosts
105
105
  # allowed_origins: ['localhost', '127.0.0.1', 'example.com', /.*\.example\.com/],
106
+ # localhost_only: true, # Set to false to allow connections from other hosts
107
+ # whitelist specific ips to if you want to run on localhost and allow connections from other IPs
108
+ # allowed_ips: ['127.0.0.1', '::1']
106
109
  # authenticate: true, # Uncomment to enable authentication
107
110
  # auth_token: 'your-token' # Required if authenticate: true
108
111
  ) do |server|
@@ -320,8 +323,10 @@ The HTTP/SSE transport validates the Origin header on all incoming connections t
320
323
 
321
324
  ```ruby
322
325
  # Configure allowed origins (defaults to ['localhost', '127.0.0.1'])
323
- FastMcp.rack_middleware(app,
326
+ FastMcp.rack_middleware(app,
324
327
  allowed_origins: ['localhost', '127.0.0.1', 'your-domain.com', /.*\.your-domain\.com/],
328
+ localhost_only: false,
329
+ allowed_ips: ['192.168.1.1', '10.0.0.1'],
325
330
  # other options...
326
331
  )
327
332
  ```
data/lib/fast_mcp.rb CHANGED
@@ -141,7 +141,10 @@ module FastMcp
141
141
  sse_route = options.delete(:sse_route) || 'sse'
142
142
  authenticate = options.delete(:authenticate) || false
143
143
  allowed_origins = options[:allowed_origins] || default_rails_allowed_origins(app)
144
+ allowed_ips = options[:allowed_ips] || FastMcp::Transports::RackTransport::DEFAULT_ALLOWED_IPS
144
145
 
146
+ options[:localhost_only] = Rails.env.local? if options[:localhost_only].nil?
147
+ options[:allowed_ips] = allowed_ips
145
148
  options[:logger] = logger
146
149
  options[:allowed_origins] = allowed_origins
147
150
 
@@ -22,7 +22,10 @@ FastMcp.mount_in_rails(
22
22
  messages_route: 'messages', # This is the default route for the messages endpoint
23
23
  sse_route: 'sse' # This is the default route for the SSE endpoint
24
24
  # Add allowed origins below, it defaults to Rails.application.config.hosts
25
- # allowed_origins: ['localhost', '127.0.0.1', 'example.com', /.*\.example\.com/],
25
+ # allowed_origins: ['localhost', '127.0.0.1', '[::1]', 'example.com', /.*\.example\.com/],
26
+ # localhost_only: true, # Set to false to allow connections from other hosts
27
+ # whitelist specific ips to if you want to run on localhost and allow connections from other IPs
28
+ # allowed_ips: ['127.0.0.1', '::1']
26
29
  # authenticate: true, # Uncomment to enable authentication
27
30
  # auth_token: 'your-token', # Required if authenticate: true
28
31
  ) do |server|
data/lib/mcp/server.rb CHANGED
@@ -275,7 +275,7 @@ module FastMcp
275
275
  @client_initialized = true
276
276
  @logger.info('Client initialized, beginning normal operation')
277
277
 
278
- send_result({}, nil)
278
+ nil
279
279
  end
280
280
 
281
281
  # Handle tools/list request
@@ -14,9 +14,7 @@ module FastMcp
14
14
  @auth_enabled = !@auth_token.nil?
15
15
  end
16
16
 
17
- def call(env)
18
- request = Rack::Request.new(env)
19
-
17
+ def handle_mcp_request(request, env)
20
18
  if auth_enabled? && !exempt_from_auth?(request.path)
21
19
  auth_header = request.env["HTTP_#{@auth_header_name.upcase.gsub('-', '_')}"]
22
20
  token = auth_header&.gsub('Bearer ', '')
@@ -42,6 +40,7 @@ module FastMcp
42
40
  end
43
41
 
44
42
  def unauthorized_response(request)
43
+ @logger.error('Unauthorized request: Invalid or missing authentication token')
45
44
  body = JSON.generate(
46
45
  {
47
46
  jsonrpc: '2.0',
@@ -11,9 +11,25 @@ module FastMcp
11
11
  # This transport can be mounted in any Rack-compatible web framework
12
12
  class RackTransport < BaseTransport # rubocop:disable Metrics/ClassLength
13
13
  DEFAULT_PATH_PREFIX = '/mcp'
14
- DEFAULT_ALLOWED_ORIGINS = ['localhost', '127.0.0.1'].freeze
15
-
16
- attr_reader :app, :path_prefix, :sse_clients, :messages_route, :sse_route, :allowed_origins
14
+ DEFAULT_ALLOWED_ORIGINS = ['localhost', '127.0.0.1', '[::1]'].freeze
15
+ DEFAULT_ALLOWED_IPS = ['127.0.0.1', '::1'].freeze
16
+
17
+ SSE_HEADERS = {
18
+ 'Content-Type' => 'text/event-stream',
19
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
20
+ 'Connection' => 'keep-alive',
21
+ 'X-Accel-Buffering' => 'no', # For Nginx
22
+ 'Access-Control-Allow-Origin' => '*', # Allow CORS
23
+ 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
24
+ 'Access-Control-Allow-Headers' => 'Content-Type',
25
+ 'Access-Control-Max-Age' => '86400', # 24 hours
26
+ 'Keep-Alive' => 'timeout=600', # 10 minutes timeout
27
+ 'Pragma' => 'no-cache',
28
+ 'Expires' => '0'
29
+ }.freeze
30
+
31
+ attr_reader :app, :path_prefix, :sse_clients, :messages_route, :sse_route, :allowed_origins, :localhost_only,
32
+ :allowed_ips
17
33
 
18
34
  def initialize(app, server, options = {}, &_block)
19
35
  super(server, logger: options[:logger])
@@ -22,6 +38,8 @@ module FastMcp
22
38
  @messages_route = options[:messages_route] || 'messages'
23
39
  @sse_route = options[:sse_route] || 'sse'
24
40
  @allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
41
+ @localhost_only = options.fetch(:localhost_only, true) # Default to localhost-only mode
42
+ @allowed_ips = options[:allowed_ips] || DEFAULT_ALLOWED_IPS
25
43
  @sse_clients = {}
26
44
  @running = false
27
45
  end
@@ -105,6 +123,18 @@ module FastMcp
105
123
 
106
124
  private
107
125
 
126
+ def validate_client_ip(request)
127
+ client_ip = request.ip
128
+
129
+ # Check if we're in localhost-only mode
130
+ if @localhost_only && !@allowed_ips.include?(client_ip)
131
+ @logger.warn("Blocked connection from non-localhost IP: #{client_ip}")
132
+ return false
133
+ end
134
+
135
+ true
136
+ end
137
+
108
138
  # Validate the Origin header to prevent DNS rebinding attacks
109
139
  def validate_origin(request, env)
110
140
  origin = env['HTTP_ORIGIN']
@@ -117,7 +147,7 @@ module FastMcp
117
147
 
118
148
  # If we have a hostname and allowed_origins is not empty
119
149
  if hostname && !allowed_origins.empty?
120
- @logger.info("Validating origin: #{hostname}")
150
+ @logger.debug("Validating origin: #{hostname}")
121
151
 
122
152
  # Check if the hostname matches any allowed origin
123
153
  is_allowed = allowed_origins.any? do |allowed|
@@ -160,20 +190,11 @@ module FastMcp
160
190
 
161
191
  # Handle MCP-specific requests
162
192
  def handle_mcp_request(request, env)
193
+ # Validate client IP to ensure it's connecting from allowed sources
194
+ return forbidden_response('Forbidden: Remote IP not allowed') unless validate_client_ip(request)
195
+
163
196
  # Validate Origin header to prevent DNS rebinding attacks
164
- unless validate_origin(request, env)
165
- return [403, { 'Content-Type' => 'application/json' },
166
- [JSON.generate(
167
- {
168
- jsonrpc: '2.0',
169
- error: {
170
- code: -32_600,
171
- message: 'Forbidden: Origin validation failed'
172
- },
173
- id: nil
174
- }
175
- )]]
176
- end
197
+ return forbidden_response('Forbidden: Origin validation failed') unless validate_origin(request, env)
177
198
 
178
199
  subpath = request.path[@path_prefix.length..]
179
200
  @logger.info("MCP request subpath: '#{subpath.inspect}'")
@@ -190,6 +211,20 @@ module FastMcp
190
211
  end
191
212
  end
192
213
 
214
+ def forbidden_response(message)
215
+ [403, { 'Content-Type' => 'application/json' },
216
+ [JSON.generate(
217
+ {
218
+ jsonrpc: '2.0',
219
+ error: {
220
+ code: -32_600,
221
+ message: message
222
+ },
223
+ id: nil
224
+ }
225
+ )]]
226
+ end
227
+
193
228
  # Return a 404 endpoint not found response
194
229
  def endpoint_not_found_response
195
230
  [404, { 'Content-Type' => 'application/json' },
@@ -212,24 +247,21 @@ module FastMcp
212
247
 
213
248
  return method_not_allowed_response unless request.get?
214
249
 
215
- # Set up SSE headers
216
- headers = setup_sse_headers
217
-
218
250
  # Handle streaming based on the framework
219
- handle_streaming(env, headers)
251
+ handle_streaming(env)
220
252
  end
221
253
 
222
254
  # Handle streaming based on the framework
223
- def handle_streaming(env, headers)
255
+ def handle_streaming(env)
224
256
  @logger.info("Handling streaming for env: #{env['HTTP_USER_AGENT']}")
225
257
  if env['rack.hijack']
226
258
  # Rack hijacking (e.g., Puma)
227
259
  @logger.info('Handling rack hijack SSE')
228
- handle_rack_hijack_sse(env, headers)
260
+ handle_rack_hijack_sse(env)
229
261
  elsif rails_live_streaming?(env)
230
262
  # Rails ActionController::Live
231
263
  @logger.info('Handling rails live streaming SSE')
232
- handle_rails_sse(env, headers)
264
+ handle_rails_sse(env)
233
265
  else
234
266
  # Fallback for servers that don't support streaming
235
267
  @logger.info('Falling back to default SSE')
@@ -244,23 +276,6 @@ module FastMcp
244
276
  env['action_controller.instance'].response.respond_to?(:stream)
245
277
  end
246
278
 
247
- # Set up headers for SSE connection
248
- def setup_sse_headers
249
- {
250
- 'Content-Type' => 'text/event-stream',
251
- 'Cache-Control' => 'no-cache, no-store, must-revalidate',
252
- 'Connection' => 'keep-alive',
253
- 'X-Accel-Buffering' => 'no', # For Nginx
254
- 'Access-Control-Allow-Origin' => '*', # Allow CORS
255
- 'Access-Control-Allow-Methods' => 'GET, OPTIONS',
256
- 'Access-Control-Allow-Headers' => 'Content-Type',
257
- 'Access-Control-Max-Age' => '86400', # 24 hours
258
- 'Keep-Alive' => 'timeout=600', # 10 minutes timeout
259
- 'Pragma' => 'no-cache',
260
- 'Expires' => '0'
261
- }
262
- end
263
-
264
279
  # Set up CORS headers for preflight requests
265
280
  def setup_cors_headers
266
281
  {
@@ -286,9 +301,6 @@ module FastMcp
286
301
  browser_type = detect_browser_type(user_agent)
287
302
  @logger.info("Client connection from: #{user_agent} (#{browser_type})")
288
303
 
289
- # Handle MCP inspector with fixed client ID
290
- @logger.info("MCP Inspector detected, using fixed client ID: #{client_id}") if mcp_inspector?(user_agent, env)
291
-
292
304
  # Handle reconnection
293
305
  if client_id && @sse_clients.key?(client_id)
294
306
  handle_client_reconnection(client_id, browser_type)
@@ -321,11 +333,6 @@ module FastMcp
321
333
  end
322
334
  end
323
335
 
324
- # Check if client is MCP inspector
325
- def mcp_inspector?(user_agent, env)
326
- user_agent.include?('mcp-inspector') || (env['mcp.client_name'] == 'mcp-inspector')
327
- end
328
-
329
336
  # Handle client reconnection
330
337
  def handle_client_reconnection(client_id, browser_type)
331
338
  @logger.info("Client #{client_id} is reconnecting (#{browser_type})")
@@ -342,7 +349,7 @@ module FastMcp
342
349
  end
343
350
 
344
351
  # Handle SSE with Rack hijacking (e.g., Puma)
345
- def handle_rack_hijack_sse(env, headers)
352
+ def handle_rack_hijack_sse(env)
346
353
  client_id = extract_client_id(env)
347
354
  @logger.debug("Setting up Rack hijack SSE connection for client #{client_id}")
348
355
 
@@ -350,7 +357,7 @@ module FastMcp
350
357
  io = env['rack.hijack_io']
351
358
  @logger.debug("Obtained hijack IO for client #{client_id}")
352
359
 
353
- setup_sse_connection(client_id, io, headers)
360
+ setup_sse_connection(client_id, io, env)
354
361
  start_keep_alive_thread(client_id, io)
355
362
 
356
363
  # Return async response
@@ -358,11 +365,11 @@ module FastMcp
358
365
  end
359
366
 
360
367
  # Set up the SSE connection
361
- def setup_sse_connection(client_id, io, headers)
368
+ def setup_sse_connection(client_id, io, env)
362
369
  # Send headers
363
370
  @logger.debug("Sending HTTP headers for SSE connection #{client_id}")
364
371
  io.write("HTTP/1.1 200 OK\r\n")
365
- headers.each { |k, v| io.write("#{k}: #{v}\r\n") }
372
+ SSE_HEADERS.each { |k, v| io.write("#{k}: #{v}\r\n") }
366
373
  io.write("\r\n")
367
374
  io.flush
368
375
 
@@ -372,8 +379,12 @@ module FastMcp
372
379
  # Send an initial comment to keep the connection alive
373
380
  io.write(": SSE connection established\n\n")
374
381
 
375
- # Send endpoint information as the first message
382
+ # Extract query parameters from the request
383
+ query_string = env['QUERY_STRING']
384
+
385
+ # Send endpoint information as the first message with query parameters
376
386
  endpoint = "#{@path_prefix}/#{@messages_route}"
387
+ endpoint += "?#{query_string}" if query_string
377
388
  @logger.debug("Sending endpoint information to client #{client_id}: #{endpoint}")
378
389
  io.write("event: endpoint\ndata: #{endpoint}\n\n")
379
390
 
@@ -405,13 +416,11 @@ module FastMcp
405
416
  @logger.info("Starting keep-alive loop for SSE connection #{client_id}")
406
417
  ping_count = 0
407
418
  ping_interval = 1 # Send a ping every 1 second
408
- max_ping_count = 30 # Reset connection after 30 pings (about 30 seconds)
409
419
  @running = true
410
420
 
411
421
  while @running && !io.closed?
412
422
  begin
413
- ping_count = send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
414
- break if ping_count >= max_ping_count
423
+ ping_count = send_keep_alive_ping(io, client_id, ping_count)
415
424
 
416
425
  sleep ping_interval
417
426
  rescue Errno::EPIPE, IOError => e
@@ -423,7 +432,7 @@ module FastMcp
423
432
  end
424
433
 
425
434
  # Send a keep-alive ping and return the updated ping count
426
- def send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
435
+ def send_keep_alive_ping(io, client_id, ping_count)
427
436
  ping_count += 1
428
437
 
429
438
  # Send a comment before each ping to keep the connection alive
@@ -436,12 +445,6 @@ module FastMcp
436
445
  send_ping_event(io)
437
446
  end
438
447
 
439
- # If we've reached the max ping count, force a reconnection
440
- if ping_count >= max_ping_count
441
- @logger.debug("Reached max ping count (#{max_ping_count}) for client #{client_id}, forcing reconnection")
442
- send_reconnect_event(io)
443
- end
444
-
445
448
  ping_count
446
449
  end
447
450
 
@@ -450,18 +453,12 @@ module FastMcp
450
453
  ping_message = {
451
454
  jsonrpc: '2.0',
452
455
  method: 'ping',
453
- id: SecureRandom.uuid
456
+ id: rand(1_000_000)
454
457
  }
455
458
  io.write("event: ping\ndata: #{JSON.generate(ping_message)}\n\n")
456
459
  io.flush
457
460
  end
458
461
 
459
- # Send a reconnect event
460
- def send_reconnect_event(io)
461
- io.write("event: reconnect\ndata: {\"reason\":\"timeout prevention\"}\n\n")
462
- io.flush
463
- end
464
-
465
462
  # Clean up SSE connection
466
463
  def cleanup_sse_connection(client_id, io)
467
464
  @logger.info("Cleaning up SSE connection for client #{client_id}")
@@ -475,7 +472,7 @@ module FastMcp
475
472
  end
476
473
 
477
474
  # Handle SSE with Rails ActionController::Live
478
- def handle_rails_sse(env, headers)
475
+ def handle_rails_sse(env)
479
476
  client_id = extract_client_id(env)
480
477
  controller = env['action_controller.instance']
481
478
  stream = controller.response.stream
@@ -484,7 +481,7 @@ module FastMcp
484
481
  register_sse_client(client_id, stream)
485
482
 
486
483
  # The controller will handle the streaming
487
- [200, headers, []]
484
+ [200, SSE_HEADERS, []]
488
485
  end
489
486
 
490
487
  # Handle message POST request
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.1.0'
4
+ VERSION = '1.3.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.3.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-04-13 00:00:00.000000000 Z
11
+ date: 2025-04-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -66,6 +66,20 @@ dependencies:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
68
  version: '3.4'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rack
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.1'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.1'
69
83
  description: A flexible and powerful implementation of the MCP with multiple approaches
70
84
  for defining tools.
71
85
  email: