fast-mcp 0.1.0 → 1.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.
@@ -5,32 +5,37 @@ require 'securerandom'
5
5
  require 'rack'
6
6
  require_relative 'base_transport'
7
7
 
8
- module MCP
8
+ module FastMcp
9
9
  module Transports
10
10
  # Rack middleware transport for MCP
11
11
  # This transport can be mounted in any Rack-compatible web framework
12
- class RackTransport < BaseTransport
12
+ class RackTransport < BaseTransport # rubocop:disable Metrics/ClassLength
13
13
  DEFAULT_PATH_PREFIX = '/mcp'
14
+ DEFAULT_ALLOWED_ORIGINS = ['localhost', '127.0.0.1'].freeze
14
15
 
15
- attr_reader :app, :path_prefix, :sse_clients
16
+ attr_reader :app, :path_prefix, :sse_clients, :messages_route, :sse_route, :allowed_origins
16
17
 
17
- def initialize(server, app, options = {})
18
+ def initialize(app, server, options = {}, &_block)
18
19
  super(server, logger: options[:logger])
19
20
  @app = app
20
21
  @path_prefix = options[:path_prefix] || DEFAULT_PATH_PREFIX
22
+ @messages_route = options[:messages_route] || 'messages'
23
+ @sse_route = options[:sse_route] || 'sse'
24
+ @allowed_origins = options[:allowed_origins] || DEFAULT_ALLOWED_ORIGINS
21
25
  @sse_clients = {}
22
26
  @running = false
23
27
  end
24
28
 
25
29
  # Start the transport
26
30
  def start
27
- @logger.info("Starting Rack transport with path prefix: #{@path_prefix}")
31
+ @logger.debug("Starting Rack transport with path prefix: #{@path_prefix}")
32
+ @logger.info("DNS rebinding protection enabled. Allowed origins: #{allowed_origins.join(', ')}")
28
33
  @running = true
29
34
  end
30
35
 
31
36
  # Stop the transport
32
37
  def stop
33
- @logger.info('Stopping Rack transport')
38
+ @logger.debug('Stopping Rack transport')
34
39
  @running = false
35
40
 
36
41
  # Close all SSE connections
@@ -45,7 +50,7 @@ module MCP
45
50
  # Send a message to all connected SSE clients
46
51
  def send_message(message)
47
52
  json_message = message.is_a?(String) ? message : JSON.generate(message)
48
- @logger.info("Broadcasting message to #{@sse_clients.size} SSE clients: #{json_message}")
53
+ @logger.debug("Broadcasting message to #{@sse_clients.size} SSE clients: #{json_message}")
49
54
 
50
55
  clients_to_remove = []
51
56
 
@@ -85,10 +90,12 @@ module MCP
85
90
  def call(env)
86
91
  request = Rack::Request.new(env)
87
92
  path = request.path
88
- @logger.info("Rack request path: #{path}")
93
+ @logger.debug("Rack request path: #{path}")
89
94
 
90
95
  # Check if the request is for our MCP endpoints
91
96
  if path.start_with?(@path_prefix)
97
+ @logger.debug('Setting server transport to RackTransport')
98
+ @server.transport = self
92
99
  handle_mcp_request(request, env)
93
100
  else
94
101
  # Pass through to the main application
@@ -98,16 +105,83 @@ module MCP
98
105
 
99
106
  private
100
107
 
108
+ # Validate the Origin header to prevent DNS rebinding attacks
109
+ def validate_origin(request, env)
110
+ origin = env['HTTP_ORIGIN']
111
+
112
+ # If no origin header is present, check the referer or host
113
+ origin = env['HTTP_REFERER'] || request.host if origin.nil? || origin.empty?
114
+
115
+ # Extract hostname from the origin
116
+ hostname = extract_hostname(origin)
117
+
118
+ # If we have a hostname and allowed_origins is not empty
119
+ if hostname && !allowed_origins.empty?
120
+ @logger.info("Validating origin: #{hostname}")
121
+
122
+ # Check if the hostname matches any allowed origin
123
+ is_allowed = allowed_origins.any? do |allowed|
124
+ if allowed.is_a?(Regexp)
125
+ hostname.match?(allowed)
126
+ else
127
+ hostname == allowed
128
+ end
129
+ end
130
+
131
+ unless is_allowed
132
+ @logger.warn("Blocked request with origin: #{hostname}")
133
+ return false
134
+ end
135
+ end
136
+
137
+ true
138
+ end
139
+
140
+ # Extract hostname from a URL
141
+ def extract_hostname(url)
142
+ return nil if url.nil? || url.empty?
143
+
144
+ begin
145
+ # Check if the URL has a scheme, if not, add http:// as a prefix
146
+ has_scheme = url.match?(%r{^[a-zA-Z][a-zA-Z0-9+.-]*://})
147
+ parsing_url = has_scheme ? url : "http://#{url}"
148
+
149
+ uri = URI.parse(parsing_url)
150
+
151
+ # Return nil for invalid URLs where host is empty
152
+ return nil if uri.host.nil? || uri.host.empty?
153
+
154
+ uri.host
155
+ rescue URI::InvalidURIError
156
+ # If standard parsing fails, try to extract host with a regex for host:port format
157
+ url.split(':').first if url.match?(%r{^([^:/]+)(:\d+)?$})
158
+ end
159
+ end
160
+
101
161
  # Handle MCP-specific requests
102
162
  def handle_mcp_request(request, env)
163
+ # 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
177
+
103
178
  subpath = request.path[@path_prefix.length..]
104
179
  @logger.info("MCP request subpath: '#{subpath.inspect}'")
105
180
 
106
181
  case subpath
107
- when '/sse'
182
+ when "/#{@sse_route}"
108
183
  handle_sse_request(request, env)
109
- when '/messages'
110
- @logger.info('Received message request')
184
+ when "/#{@messages_route}"
111
185
  handle_message_request(request)
112
186
  else
113
187
  @logger.info('Received unknown request')
@@ -270,11 +344,11 @@ module MCP
270
344
  # Handle SSE with Rack hijacking (e.g., Puma)
271
345
  def handle_rack_hijack_sse(env, headers)
272
346
  client_id = extract_client_id(env)
273
- @logger.info("Setting up Rack hijack SSE connection for client #{client_id}")
347
+ @logger.debug("Setting up Rack hijack SSE connection for client #{client_id}")
274
348
 
275
349
  env['rack.hijack'].call
276
350
  io = env['rack.hijack_io']
277
- @logger.info("Obtained hijack IO for client #{client_id}")
351
+ @logger.debug("Obtained hijack IO for client #{client_id}")
278
352
 
279
353
  setup_sse_connection(client_id, io, headers)
280
354
  start_keep_alive_thread(client_id, io)
@@ -286,7 +360,7 @@ module MCP
286
360
  # Set up the SSE connection
287
361
  def setup_sse_connection(client_id, io, headers)
288
362
  # Send headers
289
- @logger.info("Sending HTTP headers for SSE connection #{client_id}")
363
+ @logger.debug("Sending HTTP headers for SSE connection #{client_id}")
290
364
  io.write("HTTP/1.1 200 OK\r\n")
291
365
  headers.each { |k, v| io.write("#{k}: #{v}\r\n") }
292
366
  io.write("\r\n")
@@ -299,8 +373,8 @@ module MCP
299
373
  io.write(": SSE connection established\n\n")
300
374
 
301
375
  # Send endpoint information as the first message
302
- endpoint = "#{@path_prefix}/messages"
303
- @logger.info("Sending endpoint information to client #{client_id}: #{endpoint}")
376
+ endpoint = "#{@path_prefix}/#{@messages_route}"
377
+ @logger.debug("Sending endpoint information to client #{client_id}: #{endpoint}")
304
378
  io.write("event: endpoint\ndata: #{endpoint}\n\n")
305
379
 
306
380
  # Send a retry directive with a very short reconnect time
@@ -332,6 +406,7 @@ module MCP
332
406
  ping_count = 0
333
407
  ping_interval = 1 # Send a ping every 1 second
334
408
  max_ping_count = 30 # Reset connection after 30 pings (about 30 seconds)
409
+ @running = true
335
410
 
336
411
  while @running && !io.closed?
337
412
  begin
@@ -357,13 +432,13 @@ module MCP
357
432
 
358
433
  # Only send actual ping events every 5 counts to reduce overhead
359
434
  if (ping_count % 5).zero?
360
- @logger.info("Sending ping ##{ping_count} to SSE client #{client_id}")
435
+ @logger.debug("Sending ping ##{ping_count} to SSE client #{client_id}")
361
436
  send_ping_event(io)
362
437
  end
363
438
 
364
439
  # If we've reached the max ping count, force a reconnection
365
440
  if ping_count >= max_ping_count
366
- @logger.info("Reached max ping count (#{max_ping_count}) for client #{client_id}, forcing reconnection")
441
+ @logger.debug("Reached max ping count (#{max_ping_count}) for client #{client_id}, forcing reconnection")
367
442
  send_reconnect_event(io)
368
443
  end
369
444
 
@@ -414,7 +489,7 @@ module MCP
414
489
 
415
490
  # Handle message POST request
416
491
  def handle_message_request(request)
417
- @logger.info('Received message request')
492
+ @logger.debug('Received message request')
418
493
  return method_not_allowed_response unless request.post?
419
494
 
420
495
  begin
@@ -431,9 +506,10 @@ module MCP
431
506
  # Parse the request body
432
507
  body = request.body.read
433
508
 
434
- response = process_message(body)
509
+ response = process_message(body) || ''
435
510
  @logger.info("Response: #{response}")
436
- [200, { 'Content-Type' => 'application/json' }, [response]]
511
+
512
+ [200, { 'Content-Type' => 'application/json' }, response]
437
513
  end
438
514
 
439
515
  # Return a method not allowed error response
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative 'base_transport'
4
4
 
5
- module MCP
5
+ module FastMcp
6
6
  module Transports
7
7
  # STDIO transport for MCP
8
8
  # This transport uses standard input/output for communication
data/lib/mcp/version.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Version information
4
- module FastMCP
5
- VERSION = '0.1.0'
3
+ module FastMcp
4
+ VERSION = '1.1.0'
6
5
  end
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fast-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.1.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-03-23 00:00:00.000000000 Z
11
+ date: 2025-04-13 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: base64
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: dry-schema
15
29
  requirement: !ruby/object:Gem::Requirement
@@ -64,7 +78,14 @@ files:
64
78
  - LICENSE
65
79
  - README.md
66
80
  - lib/fast_mcp.rb
81
+ - lib/generators/fast_mcp/install/install_generator.rb
82
+ - lib/generators/fast_mcp/install/templates/application_resource.rb
83
+ - lib/generators/fast_mcp/install/templates/application_tool.rb
84
+ - lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb
85
+ - lib/generators/fast_mcp/install/templates/sample_resource.rb
86
+ - lib/generators/fast_mcp/install/templates/sample_tool.rb
67
87
  - lib/mcp/logger.rb
88
+ - lib/mcp/railtie.rb
68
89
  - lib/mcp/resource.rb
69
90
  - lib/mcp/server.rb
70
91
  - lib/mcp/tool.rb
@@ -80,6 +101,7 @@ metadata:
80
101
  homepage_uri: https://github.com/yjacquin/fast_mcp
81
102
  source_code_uri: https://github.com/yjacquin/fast_mcp
82
103
  changelog_uri: https://github.com/yjacquin/fast_mcp/blob/main/CHANGELOG.md
104
+ rubygems_mfa_required: 'true'
83
105
  post_install_message:
84
106
  rdoc_options: []
85
107
  require_paths: