fast-mcp 1.1.0 → 1.2.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: e9a99e9fd2c611e1645bfda1cd3d4c924f86b4284e1018e53d8f55c7612c6d48
4
+ data.tar.gz: bc9def81c86fb8db4ecdb32091209050a7d860c64d7204158ecfb98e27d01156
5
5
  SHA512:
6
- metadata.gz: eacade9a05c17a5032bbe2ed13e68bf094e7d018f272163a70f2457971c60ed79672488ab177fe3926dc9aa51cbf738ed2e3ba70bd9ab4c309599e593659ab97
7
- data.tar.gz: 390cb3617e29219da28df5c49ff7da06c36b09d341068b5bc4c20883b64dd61f33c67e0542e03f368bf686129f1338ea5af3157bac28439c824520095250adfd
6
+ metadata.gz: 44ba180b84383e3f0cff990136551101cea90181cb384634abecf72ab96ae0316ab53cf9e05eb09626642f647dac8cf2039edc808339dd11c579cd14a0333d13
7
+ data.tar.gz: 03b61251ec444d33748a23c1a786f70fea8d54228017b2b1dec0eeae93cb1900afc9af8b19d3fb3c55bbfba26a8e52e2d5ddc266fd6a19f72de692b3ef880382
data/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ 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.2.0] - UNRELEASED
9
+ ### Added
10
+ - Security enhancement: Bing only to localhost by default [#44 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/44)
11
+ - Prevent AuthenticatedRackMiddleware from blocking other rails routes[#35 @JulianPasquale](https://github.com/yjacquin/fast-mcp/pull/35)
12
+ - Stop Forcing reconnections after 30 pings [#42 @zoedsoupe](https://github.com/yjacquin/fast-mcp/pull/42)
13
+
14
+
8
15
  ## [1.1.0] - 2025-04-13
9
16
  ### Added
10
17
  - Security enhancement: Added DNS rebinding protection by validating Origin headers [#32 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/32/files)
@@ -60,5 +67,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
60
67
  - Resource management with subscription capabilities
61
68
  - Binary resource support
62
69
  - Examples with STDIO Transport, HTTP & SSE, Rack app
63
- - Initialize lifecycle with capabilities
70
+ - Initialize lifecycle with capabilities
64
71
  - 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|
@@ -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,10 @@ 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
+ attr_reader :app, :path_prefix, :sse_clients, :messages_route, :sse_route, :allowed_origins, :localhost_only,
17
+ :allowed_ips
17
18
 
18
19
  def initialize(app, server, options = {}, &_block)
19
20
  super(server, logger: options[:logger])
@@ -22,6 +23,8 @@ module FastMcp
22
23
  @messages_route = options[:messages_route] || 'messages'
23
24
  @sse_route = options[:sse_route] || 'sse'
24
25
  @allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
26
+ @localhost_only = options.fetch(:localhost_only, true) # Default to localhost-only mode
27
+ @allowed_ips = options[:allowed_ips] || DEFAULT_ALLOWED_IPS
25
28
  @sse_clients = {}
26
29
  @running = false
27
30
  end
@@ -105,6 +108,18 @@ module FastMcp
105
108
 
106
109
  private
107
110
 
111
+ def validate_client_ip(request)
112
+ client_ip = request.ip
113
+
114
+ # Check if we're in localhost-only mode
115
+ if @localhost_only && !@allowed_ips.include?(client_ip)
116
+ @logger.warn("Blocked connection from non-localhost IP: #{client_ip}")
117
+ return false
118
+ end
119
+
120
+ true
121
+ end
122
+
108
123
  # Validate the Origin header to prevent DNS rebinding attacks
109
124
  def validate_origin(request, env)
110
125
  origin = env['HTTP_ORIGIN']
@@ -117,7 +132,7 @@ module FastMcp
117
132
 
118
133
  # If we have a hostname and allowed_origins is not empty
119
134
  if hostname && !allowed_origins.empty?
120
- @logger.info("Validating origin: #{hostname}")
135
+ @logger.debug("Validating origin: #{hostname}")
121
136
 
122
137
  # Check if the hostname matches any allowed origin
123
138
  is_allowed = allowed_origins.any? do |allowed|
@@ -160,20 +175,11 @@ module FastMcp
160
175
 
161
176
  # Handle MCP-specific requests
162
177
  def handle_mcp_request(request, env)
178
+ # Validate client IP to ensure it's connecting from allowed sources
179
+ return forbidden_response('Forbidden: Remote IP not allowed') unless validate_client_ip(request)
180
+
163
181
  # 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
182
+ return forbidden_response('Forbidden: Origin validation failed') unless validate_origin(request, env)
177
183
 
178
184
  subpath = request.path[@path_prefix.length..]
179
185
  @logger.info("MCP request subpath: '#{subpath.inspect}'")
@@ -190,6 +196,20 @@ module FastMcp
190
196
  end
191
197
  end
192
198
 
199
+ def forbidden_response(message)
200
+ [403, { 'Content-Type' => 'application/json' },
201
+ [JSON.generate(
202
+ {
203
+ jsonrpc: '2.0',
204
+ error: {
205
+ code: -32_600,
206
+ message: message
207
+ },
208
+ id: nil
209
+ }
210
+ )]]
211
+ end
212
+
193
213
  # Return a 404 endpoint not found response
194
214
  def endpoint_not_found_response
195
215
  [404, { 'Content-Type' => 'application/json' },
@@ -286,9 +306,6 @@ module FastMcp
286
306
  browser_type = detect_browser_type(user_agent)
287
307
  @logger.info("Client connection from: #{user_agent} (#{browser_type})")
288
308
 
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
309
  # Handle reconnection
293
310
  if client_id && @sse_clients.key?(client_id)
294
311
  handle_client_reconnection(client_id, browser_type)
@@ -321,11 +338,6 @@ module FastMcp
321
338
  end
322
339
  end
323
340
 
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
341
  # Handle client reconnection
330
342
  def handle_client_reconnection(client_id, browser_type)
331
343
  @logger.info("Client #{client_id} is reconnecting (#{browser_type})")
@@ -405,13 +417,11 @@ module FastMcp
405
417
  @logger.info("Starting keep-alive loop for SSE connection #{client_id}")
406
418
  ping_count = 0
407
419
  ping_interval = 1 # Send a ping every 1 second
408
- max_ping_count = 30 # Reset connection after 30 pings (about 30 seconds)
409
420
  @running = true
410
421
 
411
422
  while @running && !io.closed?
412
423
  begin
413
- ping_count = send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
414
- break if ping_count >= max_ping_count
424
+ ping_count = send_keep_alive_ping(io, client_id, ping_count)
415
425
 
416
426
  sleep ping_interval
417
427
  rescue Errno::EPIPE, IOError => e
@@ -423,7 +433,7 @@ module FastMcp
423
433
  end
424
434
 
425
435
  # 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)
436
+ def send_keep_alive_ping(io, client_id, ping_count)
427
437
  ping_count += 1
428
438
 
429
439
  # Send a comment before each ping to keep the connection alive
@@ -436,12 +446,6 @@ module FastMcp
436
446
  send_ping_event(io)
437
447
  end
438
448
 
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
449
  ping_count
446
450
  end
447
451
 
@@ -450,18 +454,12 @@ module FastMcp
450
454
  ping_message = {
451
455
  jsonrpc: '2.0',
452
456
  method: 'ping',
453
- id: SecureRandom.uuid
457
+ id: rand(1_000_000)
454
458
  }
455
459
  io.write("event: ping\ndata: #{JSON.generate(ping_message)}\n\n")
456
460
  io.flush
457
461
  end
458
462
 
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
463
  # Clean up SSE connection
466
464
  def cleanup_sse_connection(client_id, io)
467
465
  @logger.info("Cleaning up SSE connection for client #{client_id}")
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.2.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.2.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-21 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64