fast-mcp 1.0.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 +4 -4
- data/CHANGELOG.md +16 -1
- data/README.md +52 -15
- data/lib/fast_mcp.rb +34 -1
- data/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +8 -1
- data/lib/mcp/server.rb +1 -1
- data/lib/mcp/transports/authenticated_rack_transport.rb +2 -3
- data/lib/mcp/transports/rack_transport.rb +102 -31
- 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: e9a99e9fd2c611e1645bfda1cd3d4c924f86b4284e1018e53d8f55c7612c6d48
|
4
|
+
data.tar.gz: bc9def81c86fb8db4ecdb32091209050a7d860c64d7204158ecfb98e27d01156
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 44ba180b84383e3f0cff990136551101cea90181cb384634abecf72ab96ae0316ab53cf9e05eb09626642f647dac8cf2039edc808339dd11c579cd14a0333d13
|
7
|
+
data.tar.gz: 03b61251ec444d33748a23c1a786f70fea8d54228017b2b1dec0eeae93cb1900afc9af8b19d3fb3c55bbfba26a8e52e2d5ddc266fd6a19f72de692b3ef880382
|
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.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
|
+
|
15
|
+
## [1.1.0] - 2025-04-13
|
16
|
+
### Added
|
17
|
+
- Security enhancement: Added DNS rebinding protection by validating Origin headers [#32 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/32/files)
|
18
|
+
- Added configuration options for allowed origins in rack middleware [#32 @yjacquin](https://github.com/yjacquin/fast-mcp/pull/32/files)
|
19
|
+
- Allow to change the SSE and Messages route [#23 @pedrofurtado](https://github.com/yjacquin/fast-mcp/pull/23)
|
20
|
+
- Fix invalid return value when processing notifications/initialized request [#31 @abMatGit](https://github.com/yjacquin/fast-mcp/pull/31)
|
21
|
+
|
22
|
+
|
8
23
|
## [1.0.0] - 2025-03-30
|
9
24
|
|
10
25
|
### Added
|
@@ -52,5 +67,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
52
67
|
- Resource management with subscription capabilities
|
53
68
|
- Binary resource support
|
54
69
|
- Examples with STDIO Transport, HTTP & SSE, Rack app
|
55
|
-
- Initialize lifecycle with capabilities
|
70
|
+
- Initialize lifecycle with capabilities
|
56
71
|
- Comprehensive test suite with RSpec
|
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,16 @@ 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/],
|
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']
|
103
109
|
# authenticate: true, # Uncomment to enable authentication
|
104
|
-
# auth_token: 'your-token'
|
110
|
+
# auth_token: 'your-token' # Required if authenticate: true
|
105
111
|
) do |server|
|
106
112
|
Rails.application.config.after_initialize do
|
107
113
|
# FastMcp will automatically discover and register:
|
@@ -136,11 +142,11 @@ These are automatically set up in Rails applications. You can use either naming
|
|
136
142
|
# Using Rails-style naming:
|
137
143
|
class MyTool < ActionTool::Base
|
138
144
|
description "My awesome tool"
|
139
|
-
|
145
|
+
|
140
146
|
arguments do
|
141
147
|
required(:input).filled(:string)
|
142
148
|
end
|
143
|
-
|
149
|
+
|
144
150
|
def call(input:)
|
145
151
|
# Your implementation
|
146
152
|
end
|
@@ -182,12 +188,12 @@ server = FastMcp::Server.new(name: 'my-ai-server', version: '1.0.0')
|
|
182
188
|
# Define a tool by inheriting from FastMcp::Tool
|
183
189
|
class SummarizeTool < FastMcp::Tool
|
184
190
|
description "Summarize a given text"
|
185
|
-
|
191
|
+
|
186
192
|
arguments do
|
187
193
|
required(:text).filled(:string).description("Text to summarize")
|
188
194
|
optional(:max_length).filled(:integer).description("Maximum length of summary")
|
189
195
|
end
|
190
|
-
|
196
|
+
|
191
197
|
def call(text:, max_length: 100)
|
192
198
|
# Your summarization logic here
|
193
199
|
text.split('.').first(3).join('.') + '...'
|
@@ -203,7 +209,7 @@ class StatisticsResource < FastMcp::Resource
|
|
203
209
|
resource_name "Usage Statistics"
|
204
210
|
description "Current system statistics"
|
205
211
|
mime_type "application/json"
|
206
|
-
|
212
|
+
|
207
213
|
def content
|
208
214
|
JSON.generate({
|
209
215
|
users_online: 120,
|
@@ -307,6 +313,36 @@ Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md)
|
|
307
313
|
- 📚 **Interactive Documentation**: Create AI-enhanced API documentation
|
308
314
|
- 💬 **Chatbots and Assistants**: Build AI assistants with access to your app's data
|
309
315
|
|
316
|
+
## 🔒 Security Features
|
317
|
+
|
318
|
+
Fast MCP includes built-in security features to protect your applications:
|
319
|
+
|
320
|
+
### DNS Rebinding Protection
|
321
|
+
|
322
|
+
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.
|
323
|
+
|
324
|
+
```ruby
|
325
|
+
# Configure allowed origins (defaults to ['localhost', '127.0.0.1'])
|
326
|
+
FastMcp.rack_middleware(app,
|
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'],
|
330
|
+
# other options...
|
331
|
+
)
|
332
|
+
```
|
333
|
+
|
334
|
+
### Authentication
|
335
|
+
|
336
|
+
Fast MCP supports token-based authentication for all connections:
|
337
|
+
|
338
|
+
```ruby
|
339
|
+
# Enable authentication
|
340
|
+
FastMcp.authenticated_rack_middleware(app,
|
341
|
+
auth_token: 'your-secret-token',
|
342
|
+
# other options...
|
343
|
+
)
|
344
|
+
```
|
345
|
+
|
310
346
|
## 📖 Documentation
|
311
347
|
|
312
348
|
- [🚀 Getting Started Guide](docs/getting_started.md)
|
@@ -315,6 +351,7 @@ Please refer to [configuring_mcp_clients](docs/configuring_mcp_clients.md)
|
|
315
351
|
- [🌐 Sinatra Integration](docs/sinatra_integration.md)
|
316
352
|
- [📚 Resources](docs/resources.md)
|
317
353
|
- [🛠️ Tools](docs/tools.md)
|
354
|
+
- [🔒 Security](docs/security.md)
|
318
355
|
|
319
356
|
## 💻 Examples
|
320
357
|
|
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,17 @@ 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)
|
144
|
+
allowed_ips = options[:allowed_ips] || FastMcp::Transports::RackTransport::DEFAULT_ALLOWED_IPS
|
134
145
|
|
146
|
+
options[:localhost_only] = Rails.env.local? if options[:localhost_only].nil?
|
147
|
+
options[:allowed_ips] = allowed_ips
|
135
148
|
options[:logger] = logger
|
149
|
+
options[:allowed_origins] = allowed_origins
|
150
|
+
|
136
151
|
# Create or get the server
|
137
152
|
self.server = FastMcp::Server.new(name: name, version: version, logger: logger)
|
138
153
|
yield self.server if block_given?
|
@@ -145,7 +160,25 @@ module FastMcp
|
|
145
160
|
end
|
146
161
|
|
147
162
|
# Insert the middleware in the Rails middleware stack
|
148
|
-
app.middleware.use
|
163
|
+
app.middleware.use(
|
164
|
+
self.server.transport_klass,
|
165
|
+
self.server,
|
166
|
+
options.merge(path_prefix: path_prefix, messages_route: messages_route, sse_route: sse_route)
|
167
|
+
)
|
168
|
+
end
|
169
|
+
|
170
|
+
def self.default_rails_allowed_origins(rail_app)
|
171
|
+
hosts = rail_app.config.hosts
|
172
|
+
|
173
|
+
hosts.map do |host|
|
174
|
+
if host.is_a?(String) && host.start_with?('.')
|
175
|
+
# Convert .domain to domain and *.domain
|
176
|
+
host_without_dot = host[1..]
|
177
|
+
[host_without_dot, Regexp.new(".*\.#{host_without_dot}")] # rubocop:disable Style/RedundantStringEscape
|
178
|
+
else
|
179
|
+
host
|
180
|
+
end
|
181
|
+
end.flatten.compact
|
149
182
|
end
|
150
183
|
|
151
184
|
# Notify the server that a resource has been updated
|
@@ -18,7 +18,14 @@ 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', '[::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']
|
22
29
|
# authenticate: true, # Uncomment to enable authentication
|
23
30
|
# auth_token: 'your-token', # Required if authenticate: true
|
24
31
|
) do |server|
|
data/lib/mcp/server.rb
CHANGED
@@ -14,9 +14,7 @@ module FastMcp
|
|
14
14
|
@auth_enabled = !@auth_token.nil?
|
15
15
|
end
|
16
16
|
|
17
|
-
def
|
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',
|
@@ -9,15 +9,22 @@ 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
|
-
|
15
|
-
|
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
|
16
18
|
|
17
19
|
def initialize(app, server, options = {}, &_block)
|
18
20
|
super(server, logger: options[:logger])
|
19
21
|
@app = app
|
20
22
|
@path_prefix = options[:path_prefix] || DEFAULT_PATH_PREFIX
|
23
|
+
@messages_route = options[:messages_route] || 'messages'
|
24
|
+
@sse_route = options[:sse_route] || 'sse'
|
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
|
21
28
|
@sse_clients = {}
|
22
29
|
@running = false
|
23
30
|
end
|
@@ -25,6 +32,7 @@ module FastMcp
|
|
25
32
|
# Start the transport
|
26
33
|
def start
|
27
34
|
@logger.debug("Starting Rack transport with path prefix: #{@path_prefix}")
|
35
|
+
@logger.info("DNS rebinding protection enabled. Allowed origins: #{allowed_origins.join(', ')}")
|
28
36
|
@running = true
|
29
37
|
end
|
30
38
|
|
@@ -100,15 +108,86 @@ module FastMcp
|
|
100
108
|
|
101
109
|
private
|
102
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
|
+
|
123
|
+
# Validate the Origin header to prevent DNS rebinding attacks
|
124
|
+
def validate_origin(request, env)
|
125
|
+
origin = env['HTTP_ORIGIN']
|
126
|
+
|
127
|
+
# If no origin header is present, check the referer or host
|
128
|
+
origin = env['HTTP_REFERER'] || request.host if origin.nil? || origin.empty?
|
129
|
+
|
130
|
+
# Extract hostname from the origin
|
131
|
+
hostname = extract_hostname(origin)
|
132
|
+
|
133
|
+
# If we have a hostname and allowed_origins is not empty
|
134
|
+
if hostname && !allowed_origins.empty?
|
135
|
+
@logger.debug("Validating origin: #{hostname}")
|
136
|
+
|
137
|
+
# Check if the hostname matches any allowed origin
|
138
|
+
is_allowed = allowed_origins.any? do |allowed|
|
139
|
+
if allowed.is_a?(Regexp)
|
140
|
+
hostname.match?(allowed)
|
141
|
+
else
|
142
|
+
hostname == allowed
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
unless is_allowed
|
147
|
+
@logger.warn("Blocked request with origin: #{hostname}")
|
148
|
+
return false
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
true
|
153
|
+
end
|
154
|
+
|
155
|
+
# Extract hostname from a URL
|
156
|
+
def extract_hostname(url)
|
157
|
+
return nil if url.nil? || url.empty?
|
158
|
+
|
159
|
+
begin
|
160
|
+
# Check if the URL has a scheme, if not, add http:// as a prefix
|
161
|
+
has_scheme = url.match?(%r{^[a-zA-Z][a-zA-Z0-9+.-]*://})
|
162
|
+
parsing_url = has_scheme ? url : "http://#{url}"
|
163
|
+
|
164
|
+
uri = URI.parse(parsing_url)
|
165
|
+
|
166
|
+
# Return nil for invalid URLs where host is empty
|
167
|
+
return nil if uri.host.nil? || uri.host.empty?
|
168
|
+
|
169
|
+
uri.host
|
170
|
+
rescue URI::InvalidURIError
|
171
|
+
# If standard parsing fails, try to extract host with a regex for host:port format
|
172
|
+
url.split(':').first if url.match?(%r{^([^:/]+)(:\d+)?$})
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
103
176
|
# Handle MCP-specific requests
|
104
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
|
+
|
181
|
+
# Validate Origin header to prevent DNS rebinding attacks
|
182
|
+
return forbidden_response('Forbidden: Origin validation failed') unless validate_origin(request, env)
|
183
|
+
|
105
184
|
subpath = request.path[@path_prefix.length..]
|
106
185
|
@logger.info("MCP request subpath: '#{subpath.inspect}'")
|
107
186
|
|
108
187
|
case subpath
|
109
|
-
when
|
188
|
+
when "/#{@sse_route}"
|
110
189
|
handle_sse_request(request, env)
|
111
|
-
when
|
190
|
+
when "/#{@messages_route}"
|
112
191
|
handle_message_request(request)
|
113
192
|
else
|
114
193
|
@logger.info('Received unknown request')
|
@@ -117,6 +196,20 @@ module FastMcp
|
|
117
196
|
end
|
118
197
|
end
|
119
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
|
+
|
120
213
|
# Return a 404 endpoint not found response
|
121
214
|
def endpoint_not_found_response
|
122
215
|
[404, { 'Content-Type' => 'application/json' },
|
@@ -213,9 +306,6 @@ module FastMcp
|
|
213
306
|
browser_type = detect_browser_type(user_agent)
|
214
307
|
@logger.info("Client connection from: #{user_agent} (#{browser_type})")
|
215
308
|
|
216
|
-
# Handle MCP inspector with fixed client ID
|
217
|
-
@logger.info("MCP Inspector detected, using fixed client ID: #{client_id}") if mcp_inspector?(user_agent, env)
|
218
|
-
|
219
309
|
# Handle reconnection
|
220
310
|
if client_id && @sse_clients.key?(client_id)
|
221
311
|
handle_client_reconnection(client_id, browser_type)
|
@@ -248,11 +338,6 @@ module FastMcp
|
|
248
338
|
end
|
249
339
|
end
|
250
340
|
|
251
|
-
# Check if client is MCP inspector
|
252
|
-
def mcp_inspector?(user_agent, env)
|
253
|
-
user_agent.include?('mcp-inspector') || (env['mcp.client_name'] == 'mcp-inspector')
|
254
|
-
end
|
255
|
-
|
256
341
|
# Handle client reconnection
|
257
342
|
def handle_client_reconnection(client_id, browser_type)
|
258
343
|
@logger.info("Client #{client_id} is reconnecting (#{browser_type})")
|
@@ -300,7 +385,7 @@ module FastMcp
|
|
300
385
|
io.write(": SSE connection established\n\n")
|
301
386
|
|
302
387
|
# Send endpoint information as the first message
|
303
|
-
endpoint = "#{@path_prefix}
|
388
|
+
endpoint = "#{@path_prefix}/#{@messages_route}"
|
304
389
|
@logger.debug("Sending endpoint information to client #{client_id}: #{endpoint}")
|
305
390
|
io.write("event: endpoint\ndata: #{endpoint}\n\n")
|
306
391
|
|
@@ -332,13 +417,11 @@ module FastMcp
|
|
332
417
|
@logger.info("Starting keep-alive loop for SSE connection #{client_id}")
|
333
418
|
ping_count = 0
|
334
419
|
ping_interval = 1 # Send a ping every 1 second
|
335
|
-
max_ping_count = 30 # Reset connection after 30 pings (about 30 seconds)
|
336
420
|
@running = true
|
337
421
|
|
338
422
|
while @running && !io.closed?
|
339
423
|
begin
|
340
|
-
ping_count = send_keep_alive_ping(io, client_id, ping_count
|
341
|
-
break if ping_count >= max_ping_count
|
424
|
+
ping_count = send_keep_alive_ping(io, client_id, ping_count)
|
342
425
|
|
343
426
|
sleep ping_interval
|
344
427
|
rescue Errno::EPIPE, IOError => e
|
@@ -350,7 +433,7 @@ module FastMcp
|
|
350
433
|
end
|
351
434
|
|
352
435
|
# Send a keep-alive ping and return the updated ping count
|
353
|
-
def send_keep_alive_ping(io, client_id, ping_count
|
436
|
+
def send_keep_alive_ping(io, client_id, ping_count)
|
354
437
|
ping_count += 1
|
355
438
|
|
356
439
|
# Send a comment before each ping to keep the connection alive
|
@@ -363,12 +446,6 @@ module FastMcp
|
|
363
446
|
send_ping_event(io)
|
364
447
|
end
|
365
448
|
|
366
|
-
# If we've reached the max ping count, force a reconnection
|
367
|
-
if ping_count >= max_ping_count
|
368
|
-
@logger.debug("Reached max ping count (#{max_ping_count}) for client #{client_id}, forcing reconnection")
|
369
|
-
send_reconnect_event(io)
|
370
|
-
end
|
371
|
-
|
372
449
|
ping_count
|
373
450
|
end
|
374
451
|
|
@@ -377,18 +454,12 @@ module FastMcp
|
|
377
454
|
ping_message = {
|
378
455
|
jsonrpc: '2.0',
|
379
456
|
method: 'ping',
|
380
|
-
id:
|
457
|
+
id: rand(1_000_000)
|
381
458
|
}
|
382
459
|
io.write("event: ping\ndata: #{JSON.generate(ping_message)}\n\n")
|
383
460
|
io.flush
|
384
461
|
end
|
385
462
|
|
386
|
-
# Send a reconnect event
|
387
|
-
def send_reconnect_event(io)
|
388
|
-
io.write("event: reconnect\ndata: {\"reason\":\"timeout prevention\"}\n\n")
|
389
|
-
io.flush
|
390
|
-
end
|
391
|
-
|
392
463
|
# Clean up SSE connection
|
393
464
|
def cleanup_sse_connection(client_id, io)
|
394
465
|
@logger.info("Cleaning up SSE connection for client #{client_id}")
|
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.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-
|
11
|
+
date: 2025-04-21 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: base64
|