fast-mcp 1.0.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 +8 -0
- data/README.md +47 -15
- data/lib/fast_mcp.rb +31 -1
- data/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +5 -1
- data/lib/mcp/server.rb +1 -1
- data/lib/mcp/transports/rack_transport.rb +78 -5
- data/lib/mcp/version.rb +1 -2
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dde8305abcf47ffc314fc95df26467f737957e1b97eecae8c2c4a88b34c76017
|
4
|
+
data.tar.gz: 22715faf289cd3f45090480f59585220c3bb904b2a15c9669c5b93700a85e104
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: eacade9a05c17a5032bbe2ed13e68bf094e7d018f272163a70f2457971c60ed79672488ab177fe3926dc9aa51cbf738ed2e3ba70bd9ab4c309599e593659ab97
|
7
|
+
data.tar.gz: 390cb3617e29219da28df5c49ff7da06c36b09d341068b5bc4c20883b64dd61f33c67e0542e03f368bf686129f1338ea5af3157bac28439c824520095250adfd
|
data/CHANGELOG.md
CHANGED
@@ -5,6 +5,14 @@ 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.1.0] - 2025-04-13
|
9
|
+
### Added
|
10
|
+
- Security enhancement: Added DNS rebinding protection by validating Origin headers [#32 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/32/files)
|
11
|
+
- Added configuration options for allowed origins in rack middleware [#32 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/32/files)
|
12
|
+
- Allow to change the SSE and Messages route [#23 @pedrofurtado](https://github.com/yjacquin/fast-mcp/pull/23)
|
13
|
+
- Fix invalid return value when processing notifications/initialized request [#31 @abMatGit](https://github.com/yjacquin/fast-mcp/pull/31)
|
14
|
+
|
15
|
+
|
8
16
|
## [1.0.0] - 2025-03-30
|
9
17
|
|
10
18
|
### Added
|
data/README.md
CHANGED
@@ -36,12 +36,11 @@ Fast MCP solves all these problems by providing a clean, Ruby-focused implementa
|
|
36
36
|
## 💎 What Makes FastMCP Great
|
37
37
|
```ruby
|
38
38
|
# Define tools for AI models to use
|
39
|
-
server = FastMcp::Server.new(name: '
|
39
|
+
server = FastMcp::Server.new(name: 'popular-users', version: '1.0.0')
|
40
40
|
|
41
41
|
# Define a tool by inheriting from FastMcp::Tool
|
42
42
|
class CreateUserTool < FastMcp::Tool
|
43
|
-
description "
|
44
|
-
|
43
|
+
description "Create a user"
|
45
44
|
# These arguments will generate the needed JSON to be presented to the MCP Client
|
46
45
|
# And they will be validated at run time.
|
47
46
|
# The validation is based off Dry-Schema, with the addition of the description.
|
@@ -54,7 +53,7 @@ class CreateUserTool < FastMcp::Tool
|
|
54
53
|
optional(:zipcode).filled(:string)
|
55
54
|
end
|
56
55
|
end
|
57
|
-
|
56
|
+
|
58
57
|
def call(first_name:, age: nil, address: {})
|
59
58
|
User.create!(first_name:, age:, address:)
|
60
59
|
end
|
@@ -68,20 +67,20 @@ class PopularUsers < FastMcp::Resource
|
|
68
67
|
uri "file://popular_users.json"
|
69
68
|
resource_name "Popular Users"
|
70
69
|
mime_type "application/json"
|
71
|
-
|
70
|
+
|
72
71
|
def content
|
73
72
|
JSON.generate(User.popular.limit(5).as_json)
|
74
73
|
end
|
75
74
|
end
|
76
75
|
|
77
76
|
# Register the resource with the server
|
78
|
-
server.register_resource(
|
77
|
+
server.register_resource(PopularUsers)
|
79
78
|
|
80
79
|
# Accessing the resource through the server
|
81
|
-
server.read_resource(
|
80
|
+
server.read_resource(PopularUsers.uri)
|
82
81
|
|
83
82
|
# Notify the resource content has been updated to clients
|
84
|
-
server.notify_resource_updated(
|
83
|
+
server.notify_resource_updated(PopularUsers.uri)
|
85
84
|
```
|
86
85
|
|
87
86
|
### 🚂 Fast Ruby on Rails implementation
|
@@ -99,9 +98,13 @@ FastMcp.mount_in_rails(
|
|
99
98
|
Rails.application,
|
100
99
|
name: Rails.application.class.module_parent_name.underscore.dasherize,
|
101
100
|
version: '1.0.0',
|
102
|
-
path_prefix: '/mcp' # This is the default path prefix
|
101
|
+
path_prefix: '/mcp', # This is the default path prefix
|
102
|
+
messages_route: 'messages', # This is the default route for the messages endpoint
|
103
|
+
sse_route: 'sse', # This is the default route for the SSE endpoint
|
104
|
+
# Add allowed origins below, it defaults to Rails.application.config.hosts
|
105
|
+
# allowed_origins: ['localhost', '127.0.0.1', 'example.com', /.*\.example\.com/],
|
103
106
|
# authenticate: true, # Uncomment to enable authentication
|
104
|
-
# auth_token: 'your-token'
|
107
|
+
# auth_token: 'your-token' # Required if authenticate: true
|
105
108
|
) do |server|
|
106
109
|
Rails.application.config.after_initialize do
|
107
110
|
# FastMcp will automatically discover and register:
|
@@ -136,11 +139,11 @@ These are automatically set up in Rails applications. You can use either naming
|
|
136
139
|
# Using Rails-style naming:
|
137
140
|
class MyTool < ActionTool::Base
|
138
141
|
description "My awesome tool"
|
139
|
-
|
142
|
+
|
140
143
|
arguments do
|
141
144
|
required(:input).filled(:string)
|
142
145
|
end
|
143
|
-
|
146
|
+
|
144
147
|
def call(input:)
|
145
148
|
# Your implementation
|
146
149
|
end
|
@@ -182,12 +185,12 @@ server = FastMcp::Server.new(name: 'my-ai-server', version: '1.0.0')
|
|
182
185
|
# Define a tool by inheriting from FastMcp::Tool
|
183
186
|
class SummarizeTool < FastMcp::Tool
|
184
187
|
description "Summarize a given text"
|
185
|
-
|
188
|
+
|
186
189
|
arguments do
|
187
190
|
required(:text).filled(:string).description("Text to summarize")
|
188
191
|
optional(:max_length).filled(:integer).description("Maximum length of summary")
|
189
192
|
end
|
190
|
-
|
193
|
+
|
191
194
|
def call(text:, max_length: 100)
|
192
195
|
# Your summarization logic here
|
193
196
|
text.split('.').first(3).join('.') + '...'
|
@@ -203,7 +206,7 @@ class StatisticsResource < FastMcp::Resource
|
|
203
206
|
resource_name "Usage Statistics"
|
204
207
|
description "Current system statistics"
|
205
208
|
mime_type "application/json"
|
206
|
-
|
209
|
+
|
207
210
|
def content
|
208
211
|
JSON.generate({
|
209
212
|
users_online: 120,
|
@@ -307,6 +310,34 @@ Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md)
|
|
307
310
|
- 📚 **Interactive Documentation**: Create AI-enhanced API documentation
|
308
311
|
- 💬 **Chatbots and Assistants**: Build AI assistants with access to your app's data
|
309
312
|
|
313
|
+
## 🔒 Security Features
|
314
|
+
|
315
|
+
Fast MCP includes built-in security features to protect your applications:
|
316
|
+
|
317
|
+
### DNS Rebinding Protection
|
318
|
+
|
319
|
+
The HTTP/SSE transport validates the Origin header on all incoming connections to prevent DNS rebinding attacks, which could allow malicious websites to interact with local MCP servers.
|
320
|
+
|
321
|
+
```ruby
|
322
|
+
# Configure allowed origins (defaults to ['localhost', '127.0.0.1'])
|
323
|
+
FastMcp.rack_middleware(app,
|
324
|
+
allowed_origins: ['localhost', '127.0.0.1', 'your-domain.com', /.*\.your-domain\.com/],
|
325
|
+
# other options...
|
326
|
+
)
|
327
|
+
```
|
328
|
+
|
329
|
+
### Authentication
|
330
|
+
|
331
|
+
Fast MCP supports token-based authentication for all connections:
|
332
|
+
|
333
|
+
```ruby
|
334
|
+
# Enable authentication
|
335
|
+
FastMcp.authenticated_rack_middleware(app,
|
336
|
+
auth_token: 'your-secret-token',
|
337
|
+
# other options...
|
338
|
+
)
|
339
|
+
```
|
340
|
+
|
310
341
|
## 📖 Documentation
|
311
342
|
|
312
343
|
- [🚀 Getting Started Guide](docs/getting_started.md)
|
@@ -315,6 +346,7 @@ Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md)
|
|
315
346
|
- [🌐 Sinatra Integration](docs/sinatra_integration.md)
|
316
347
|
- [📚 Resources](docs/resources.md)
|
317
348
|
- [🛠️ Tools](docs/tools.md)
|
349
|
+
- [🔒 Security](docs/security.md)
|
318
350
|
|
319
351
|
## 💻 Examples
|
320
352
|
|
data/lib/fast_mcp.rb
CHANGED
@@ -36,7 +36,10 @@ module FastMcp
|
|
36
36
|
# @option options [String] :name The name of the server
|
37
37
|
# @option options [String] :version The version of the server
|
38
38
|
# @option options [String] :path_prefix The path prefix for the MCP endpoints
|
39
|
+
# @option options [String] :messages_route The route for the messages endpoint
|
40
|
+
# @option options [String] :sse_route The route for the SSE endpoint
|
39
41
|
# @option options [Logger] :logger The logger to use
|
42
|
+
# @option options [Array<String,Regexp>] :allowed_origins List of allowed origins for DNS rebinding protection
|
40
43
|
# @yield [server] A block to configure the server
|
41
44
|
# @yieldparam server [FastMcp::Server] The server to configure
|
42
45
|
# @return [#call] The Rack middleware
|
@@ -63,6 +66,7 @@ module FastMcp
|
|
63
66
|
# @option options [String] :name The name of the server
|
64
67
|
# @option options [String] :version The version of the server
|
65
68
|
# @option options [String] :auth_token The authentication token
|
69
|
+
# @option options [Array<String,Regexp>] :allowed_origins List of allowed origins for DNS rebinding protection
|
66
70
|
# @yield [server] A block to configure the server
|
67
71
|
# @yieldparam server [FastMcp::Server] The server to configure
|
68
72
|
# @return [#call] The Rack middleware
|
@@ -118,9 +122,12 @@ module FastMcp
|
|
118
122
|
# @option options [String] :name The name of the server
|
119
123
|
# @option options [String] :version The version of the server
|
120
124
|
# @option options [String] :path_prefix The path prefix for the MCP endpoints
|
125
|
+
# @option options [String] :messages_route The route for the messages endpoint
|
126
|
+
# @option options [String] :sse_route The route for the SSE endpoint
|
121
127
|
# @option options [Logger] :logger The logger to use
|
122
128
|
# @option options [Boolean] :authenticate Whether to use authentication
|
123
129
|
# @option options [String] :auth_token The authentication token
|
130
|
+
# @option options [Array<String,Regexp>] :allowed_origins List of allowed origins for DNS rebinding protection
|
124
131
|
# @yield [server] A block to configure the server
|
125
132
|
# @yieldparam server [FastMcp::Server] The server to configure
|
126
133
|
# @return [#call] The Rack middleware
|
@@ -130,9 +137,14 @@ module FastMcp
|
|
130
137
|
version = options.delete(:version) || '1.0.0'
|
131
138
|
logger = options[:logger] || Rails.logger
|
132
139
|
path_prefix = options.delete(:path_prefix) || '/mcp'
|
140
|
+
messages_route = options.delete(:messages_route) || 'messages'
|
141
|
+
sse_route = options.delete(:sse_route) || 'sse'
|
133
142
|
authenticate = options.delete(:authenticate) || false
|
143
|
+
allowed_origins = options[:allowed_origins] || default_rails_allowed_origins(app)
|
134
144
|
|
135
145
|
options[:logger] = logger
|
146
|
+
options[:allowed_origins] = allowed_origins
|
147
|
+
|
136
148
|
# Create or get the server
|
137
149
|
self.server = FastMcp::Server.new(name: name, version: version, logger: logger)
|
138
150
|
yield self.server if block_given?
|
@@ -145,7 +157,25 @@ module FastMcp
|
|
145
157
|
end
|
146
158
|
|
147
159
|
# Insert the middleware in the Rails middleware stack
|
148
|
-
app.middleware.use
|
160
|
+
app.middleware.use(
|
161
|
+
self.server.transport_klass,
|
162
|
+
self.server,
|
163
|
+
options.merge(path_prefix: path_prefix, messages_route: messages_route, sse_route: sse_route)
|
164
|
+
)
|
165
|
+
end
|
166
|
+
|
167
|
+
def self.default_rails_allowed_origins(rail_app)
|
168
|
+
hosts = rail_app.config.hosts
|
169
|
+
|
170
|
+
hosts.map do |host|
|
171
|
+
if host.is_a?(String) && host.start_with?('.')
|
172
|
+
# Convert .domain to domain and *.domain
|
173
|
+
host_without_dot = host[1..]
|
174
|
+
[host_without_dot, Regexp.new(".*\.#{host_without_dot}")] # rubocop:disable Style/RedundantStringEscape
|
175
|
+
else
|
176
|
+
host
|
177
|
+
end
|
178
|
+
end.flatten.compact
|
149
179
|
end
|
150
180
|
|
151
181
|
# Notify the server that a resource has been updated
|
@@ -18,7 +18,11 @@ FastMcp.mount_in_rails(
|
|
18
18
|
Rails.application,
|
19
19
|
name: Rails.application.class.module_parent_name.underscore.dasherize,
|
20
20
|
version: '1.0.0',
|
21
|
-
path_prefix: '/mcp' # This is the default path prefix
|
21
|
+
path_prefix: '/mcp', # This is the default path prefix
|
22
|
+
messages_route: 'messages', # This is the default route for the messages endpoint
|
23
|
+
sse_route: 'sse' # This is the default route for the SSE endpoint
|
24
|
+
# Add allowed origins below, it defaults to Rails.application.config.hosts
|
25
|
+
# allowed_origins: ['localhost', '127.0.0.1', 'example.com', /.*\.example\.com/],
|
22
26
|
# authenticate: true, # Uncomment to enable authentication
|
23
27
|
# auth_token: 'your-token', # Required if authenticate: true
|
24
28
|
) do |server|
|
data/lib/mcp/server.rb
CHANGED
@@ -9,15 +9,19 @@ 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
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
|
@@ -25,6 +29,7 @@ module FastMcp
|
|
25
29
|
# Start the transport
|
26
30
|
def start
|
27
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
|
|
@@ -100,15 +105,83 @@ module FastMcp
|
|
100
105
|
|
101
106
|
private
|
102
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
|
+
|
103
161
|
# Handle MCP-specific requests
|
104
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
|
+
|
105
178
|
subpath = request.path[@path_prefix.length..]
|
106
179
|
@logger.info("MCP request subpath: '#{subpath.inspect}'")
|
107
180
|
|
108
181
|
case subpath
|
109
|
-
when
|
182
|
+
when "/#{@sse_route}"
|
110
183
|
handle_sse_request(request, env)
|
111
|
-
when
|
184
|
+
when "/#{@messages_route}"
|
112
185
|
handle_message_request(request)
|
113
186
|
else
|
114
187
|
@logger.info('Received unknown request')
|
@@ -300,7 +373,7 @@ module FastMcp
|
|
300
373
|
io.write(": SSE connection established\n\n")
|
301
374
|
|
302
375
|
# Send endpoint information as the first message
|
303
|
-
endpoint = "#{@path_prefix}
|
376
|
+
endpoint = "#{@path_prefix}/#{@messages_route}"
|
304
377
|
@logger.debug("Sending endpoint information to client #{client_id}: #{endpoint}")
|
305
378
|
io.write("event: endpoint\ndata: #{endpoint}\n\n")
|
306
379
|
|
data/lib/mcp/version.rb
CHANGED
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.
|
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
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: base64
|