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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -2
- data/README.md +173 -107
- data/lib/fast_mcp.rb +123 -6
- 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 +39 -0
- data/lib/generators/fast_mcp/install/templates/sample_resource.rb +12 -0
- data/lib/generators/fast_mcp/install/templates/sample_tool.rb +16 -0
- data/lib/mcp/logger.rb +13 -14
- data/lib/mcp/railtie.rb +45 -0
- data/lib/mcp/resource.rb +5 -10
- data/lib/mcp/server.rb +40 -74
- data/lib/mcp/tool.rb +8 -2
- data/lib/mcp/transports/authenticated_rack_transport.rb +2 -2
- data/lib/mcp/transports/base_transport.rb +1 -1
- data/lib/mcp/transports/rack_transport.rb +97 -21
- data/lib/mcp/transports/stdio_transport.rb +1 -1
- data/lib/mcp/version.rb +2 -3
- metadata +24 -2
@@ -5,32 +5,37 @@ require 'securerandom'
|
|
5
5
|
require 'rack'
|
6
6
|
require_relative 'base_transport'
|
7
7
|
|
8
|
-
module
|
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(
|
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.
|
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.
|
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.
|
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.
|
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
|
182
|
+
when "/#{@sse_route}"
|
108
183
|
handle_sse_request(request, env)
|
109
|
-
when
|
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.
|
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.
|
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.
|
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}
|
303
|
-
@logger.
|
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.
|
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.
|
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.
|
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
|
-
|
511
|
+
|
512
|
+
[200, { 'Content-Type' => 'application/json' }, response]
|
437
513
|
end
|
438
514
|
|
439
515
|
# Return a method not allowed error response
|
data/lib/mcp/version.rb
CHANGED
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:
|
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-
|
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:
|