fast-mcp 0.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +321 -0
- data/lib/fast_mcp.rb +69 -0
- data/lib/mcp/logger.rb +33 -0
- data/lib/mcp/resource.rb +158 -0
- data/lib/mcp/server.rb +491 -0
- data/lib/mcp/tool.rb +808 -0
- data/lib/mcp/transports/authenticated_rack_transport.rb +72 -0
- data/lib/mcp/transports/base_transport.rb +40 -0
- data/lib/mcp/transports/rack_transport.rb +468 -0
- data/lib/mcp/transports/stdio_transport.rb +62 -0
- data/lib/mcp/version.rb +6 -0
- metadata +102 -0
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'rack_transport'
|
4
|
+
|
5
|
+
module MCP
|
6
|
+
module Transports
|
7
|
+
class AuthenticatedRackTransport < RackTransport
|
8
|
+
def initialize(server, app, options = {})
|
9
|
+
super
|
10
|
+
|
11
|
+
@auth_token = options[:auth_token]
|
12
|
+
@auth_header_name = options[:auth_header_name] || 'Authorization'
|
13
|
+
@auth_exempt_paths = options[:auth_exempt_paths] || []
|
14
|
+
@auth_enabled = !@auth_token.nil?
|
15
|
+
end
|
16
|
+
|
17
|
+
def call(env)
|
18
|
+
request = Rack::Request.new(env)
|
19
|
+
|
20
|
+
if auth_enabled? && !exempt_from_auth?(request.path)
|
21
|
+
auth_header = request.env["HTTP_#{@auth_header_name.upcase.gsub('-', '_')}"]
|
22
|
+
token = auth_header&.gsub('Bearer ', '')
|
23
|
+
|
24
|
+
return unauthorized_response(request) unless valid_token?(token)
|
25
|
+
end
|
26
|
+
|
27
|
+
super
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def auth_enabled?
|
33
|
+
@auth_enabled
|
34
|
+
end
|
35
|
+
|
36
|
+
def exempt_from_auth?(path)
|
37
|
+
@auth_exempt_paths.any? { |exempt_path| path.start_with?(exempt_path) }
|
38
|
+
end
|
39
|
+
|
40
|
+
def valid_token?(token)
|
41
|
+
token == @auth_token
|
42
|
+
end
|
43
|
+
|
44
|
+
def unauthorized_response(request)
|
45
|
+
body = JSON.generate(
|
46
|
+
{
|
47
|
+
jsonrpc: '2.0',
|
48
|
+
error: {
|
49
|
+
code: -32_000,
|
50
|
+
message: 'Unauthorized: Invalid or missing authentication token'
|
51
|
+
},
|
52
|
+
id: extract_request_id(request)
|
53
|
+
}
|
54
|
+
)
|
55
|
+
|
56
|
+
[401, { 'Content-Type' => 'application/json' }, [body]]
|
57
|
+
end
|
58
|
+
|
59
|
+
def extract_request_id(request)
|
60
|
+
return nil unless request.post?
|
61
|
+
|
62
|
+
begin
|
63
|
+
body = request.body.read
|
64
|
+
request.body.rewind
|
65
|
+
JSON.parse(body)['id']
|
66
|
+
rescue StandardError
|
67
|
+
nil
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MCP
|
4
|
+
module Transports
|
5
|
+
# Base class for all MCP transports
|
6
|
+
# This defines the interface that all transports must implement
|
7
|
+
class BaseTransport
|
8
|
+
attr_reader :server, :logger
|
9
|
+
|
10
|
+
def initialize(server, logger: nil)
|
11
|
+
@server = server
|
12
|
+
@logger = logger || server.logger
|
13
|
+
end
|
14
|
+
|
15
|
+
# Start the transport
|
16
|
+
# This method should be implemented by subclasses
|
17
|
+
def start
|
18
|
+
raise NotImplementedError, "#{self.class} must implement #start"
|
19
|
+
end
|
20
|
+
|
21
|
+
# Stop the transport
|
22
|
+
# This method should be implemented by subclasses
|
23
|
+
def stop
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #stop"
|
25
|
+
end
|
26
|
+
|
27
|
+
# Send a message to the client
|
28
|
+
# This method should be implemented by subclasses
|
29
|
+
def send_message(message)
|
30
|
+
raise NotImplementedError, "#{self.class} must implement #send_message"
|
31
|
+
end
|
32
|
+
|
33
|
+
# Process an incoming message
|
34
|
+
# This is a helper method that can be used by subclasses
|
35
|
+
def process_message(message)
|
36
|
+
server.handle_json_request(message)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,468 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'securerandom'
|
5
|
+
require 'rack'
|
6
|
+
require_relative 'base_transport'
|
7
|
+
|
8
|
+
module MCP
|
9
|
+
module Transports
|
10
|
+
# Rack middleware transport for MCP
|
11
|
+
# This transport can be mounted in any Rack-compatible web framework
|
12
|
+
class RackTransport < BaseTransport
|
13
|
+
DEFAULT_PATH_PREFIX = '/mcp'
|
14
|
+
|
15
|
+
attr_reader :app, :path_prefix, :sse_clients
|
16
|
+
|
17
|
+
def initialize(server, app, options = {})
|
18
|
+
super(server, logger: options[:logger])
|
19
|
+
@app = app
|
20
|
+
@path_prefix = options[:path_prefix] || DEFAULT_PATH_PREFIX
|
21
|
+
@sse_clients = {}
|
22
|
+
@running = false
|
23
|
+
end
|
24
|
+
|
25
|
+
# Start the transport
|
26
|
+
def start
|
27
|
+
@logger.info("Starting Rack transport with path prefix: #{@path_prefix}")
|
28
|
+
@running = true
|
29
|
+
end
|
30
|
+
|
31
|
+
# Stop the transport
|
32
|
+
def stop
|
33
|
+
@logger.info('Stopping Rack transport')
|
34
|
+
@running = false
|
35
|
+
|
36
|
+
# Close all SSE connections
|
37
|
+
@sse_clients.each_value do |client|
|
38
|
+
client[:stream].close if client[:stream].respond_to?(:close) && !client[:stream].closed?
|
39
|
+
rescue StandardError => e
|
40
|
+
@logger.error("Error closing SSE connection: #{e.message}")
|
41
|
+
end
|
42
|
+
@sse_clients.clear
|
43
|
+
end
|
44
|
+
|
45
|
+
# Send a message to all connected SSE clients
|
46
|
+
def send_message(message)
|
47
|
+
json_message = message.is_a?(String) ? message : JSON.generate(message)
|
48
|
+
@logger.info("Broadcasting message to #{@sse_clients.size} SSE clients: #{json_message}")
|
49
|
+
|
50
|
+
clients_to_remove = []
|
51
|
+
|
52
|
+
@sse_clients.each do |client_id, client|
|
53
|
+
stream = client[:stream]
|
54
|
+
next if stream.nil? || (stream.respond_to?(:closed?) && stream.closed?)
|
55
|
+
|
56
|
+
stream.write("data: #{json_message}\n\n")
|
57
|
+
stream.flush if stream.respond_to?(:flush)
|
58
|
+
rescue Errno::EPIPE, IOError => e
|
59
|
+
# Broken pipe or IO error - client disconnected
|
60
|
+
@logger.info("Client #{client_id} disconnected: #{e.message}")
|
61
|
+
clients_to_remove << client_id
|
62
|
+
rescue StandardError => e
|
63
|
+
@logger.error("Error sending message to client #{client_id}: #{e.message}")
|
64
|
+
# Remove the client if we can't send to it
|
65
|
+
clients_to_remove << client_id
|
66
|
+
end
|
67
|
+
|
68
|
+
# Remove disconnected clients outside the loop to avoid modifying the hash during iteration
|
69
|
+
clients_to_remove.each { |client_id| unregister_sse_client(client_id) }
|
70
|
+
end
|
71
|
+
|
72
|
+
# Register a new SSE client
|
73
|
+
def register_sse_client(client_id, stream)
|
74
|
+
@logger.info("Registering SSE client: #{client_id}")
|
75
|
+
@sse_clients[client_id] = { stream: stream, connected_at: Time.now }
|
76
|
+
end
|
77
|
+
|
78
|
+
# Unregister an SSE client
|
79
|
+
def unregister_sse_client(client_id)
|
80
|
+
@logger.info("Unregistering SSE client: #{client_id}")
|
81
|
+
@sse_clients.delete(client_id)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Rack call method
|
85
|
+
def call(env)
|
86
|
+
request = Rack::Request.new(env)
|
87
|
+
path = request.path
|
88
|
+
@logger.info("Rack request path: #{path}")
|
89
|
+
|
90
|
+
# Check if the request is for our MCP endpoints
|
91
|
+
if path.start_with?(@path_prefix)
|
92
|
+
handle_mcp_request(request, env)
|
93
|
+
else
|
94
|
+
# Pass through to the main application
|
95
|
+
@app.call(env)
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
private
|
100
|
+
|
101
|
+
# Handle MCP-specific requests
|
102
|
+
def handle_mcp_request(request, env)
|
103
|
+
subpath = request.path[@path_prefix.length..]
|
104
|
+
@logger.info("MCP request subpath: '#{subpath.inspect}'")
|
105
|
+
|
106
|
+
case subpath
|
107
|
+
when '/sse'
|
108
|
+
handle_sse_request(request, env)
|
109
|
+
when '/messages'
|
110
|
+
@logger.info('Received message request')
|
111
|
+
handle_message_request(request)
|
112
|
+
else
|
113
|
+
@logger.info('Received unknown request')
|
114
|
+
# Return 404 for unknown MCP endpoints
|
115
|
+
endpoint_not_found_response
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Return a 404 endpoint not found response
|
120
|
+
def endpoint_not_found_response
|
121
|
+
[404, { 'Content-Type' => 'application/json' },
|
122
|
+
[JSON.generate(
|
123
|
+
{
|
124
|
+
jsonrpc: '2.0',
|
125
|
+
error: {
|
126
|
+
code: -32_601,
|
127
|
+
message: 'Endpoint not found'
|
128
|
+
},
|
129
|
+
id: nil
|
130
|
+
}
|
131
|
+
)]]
|
132
|
+
end
|
133
|
+
|
134
|
+
# Handle SSE connection request
|
135
|
+
def handle_sse_request(request, env)
|
136
|
+
# Handle OPTIONS preflight request
|
137
|
+
return [200, setup_cors_headers, []] if request.options?
|
138
|
+
|
139
|
+
return method_not_allowed_response unless request.get?
|
140
|
+
|
141
|
+
# Set up SSE headers
|
142
|
+
headers = setup_sse_headers
|
143
|
+
|
144
|
+
# Handle streaming based on the framework
|
145
|
+
handle_streaming(env, headers)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Handle streaming based on the framework
|
149
|
+
def handle_streaming(env, headers)
|
150
|
+
@logger.info("Handling streaming for env: #{env['HTTP_USER_AGENT']}")
|
151
|
+
if env['rack.hijack']
|
152
|
+
# Rack hijacking (e.g., Puma)
|
153
|
+
@logger.info('Handling rack hijack SSE')
|
154
|
+
handle_rack_hijack_sse(env, headers)
|
155
|
+
elsif rails_live_streaming?(env)
|
156
|
+
# Rails ActionController::Live
|
157
|
+
@logger.info('Handling rails live streaming SSE')
|
158
|
+
handle_rails_sse(env, headers)
|
159
|
+
else
|
160
|
+
# Fallback for servers that don't support streaming
|
161
|
+
@logger.info('Falling back to default SSE')
|
162
|
+
[200, headers, [":ok\n\n"]]
|
163
|
+
end
|
164
|
+
end
|
165
|
+
|
166
|
+
# Check if Rails live streaming is available
|
167
|
+
def rails_live_streaming?(env)
|
168
|
+
defined?(ActionController::Live) &&
|
169
|
+
env['action_controller.instance'].respond_to?(:response) &&
|
170
|
+
env['action_controller.instance'].response.respond_to?(:stream)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Set up headers for SSE connection
|
174
|
+
def setup_sse_headers
|
175
|
+
{
|
176
|
+
'Content-Type' => 'text/event-stream',
|
177
|
+
'Cache-Control' => 'no-cache, no-store, must-revalidate',
|
178
|
+
'Connection' => 'keep-alive',
|
179
|
+
'X-Accel-Buffering' => 'no', # For Nginx
|
180
|
+
'Access-Control-Allow-Origin' => '*', # Allow CORS
|
181
|
+
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
|
182
|
+
'Access-Control-Allow-Headers' => 'Content-Type',
|
183
|
+
'Access-Control-Max-Age' => '86400', # 24 hours
|
184
|
+
'Keep-Alive' => 'timeout=600', # 10 minutes timeout
|
185
|
+
'Pragma' => 'no-cache',
|
186
|
+
'Expires' => '0'
|
187
|
+
}
|
188
|
+
end
|
189
|
+
|
190
|
+
# Set up CORS headers for preflight requests
|
191
|
+
def setup_cors_headers
|
192
|
+
{
|
193
|
+
'Access-Control-Allow-Origin' => '*',
|
194
|
+
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
|
195
|
+
'Access-Control-Allow-Headers' => 'Content-Type',
|
196
|
+
'Access-Control-Max-Age' => '86400', # 24 hours
|
197
|
+
'Content-Type' => 'text/plain'
|
198
|
+
}
|
199
|
+
end
|
200
|
+
|
201
|
+
# Extract client ID from request or generate a new one
|
202
|
+
def extract_client_id(env)
|
203
|
+
request = Rack::Request.new(env)
|
204
|
+
|
205
|
+
# Check various places for client ID
|
206
|
+
client_id = request.params['client_id']
|
207
|
+
client_id ||= env['HTTP_LAST_EVENT_ID']
|
208
|
+
client_id ||= env['HTTP_X_CLIENT_ID']
|
209
|
+
|
210
|
+
# Get browser information
|
211
|
+
user_agent = env['HTTP_USER_AGENT'] || ''
|
212
|
+
browser_type = detect_browser_type(user_agent)
|
213
|
+
@logger.info("Client connection from: #{user_agent} (#{browser_type})")
|
214
|
+
|
215
|
+
# Handle MCP inspector with fixed client ID
|
216
|
+
@logger.info("MCP Inspector detected, using fixed client ID: #{client_id}") if mcp_inspector?(user_agent, env)
|
217
|
+
|
218
|
+
# Handle reconnection
|
219
|
+
if client_id && @sse_clients.key?(client_id)
|
220
|
+
handle_client_reconnection(client_id, browser_type)
|
221
|
+
else
|
222
|
+
# Generate a new client ID if none was provided
|
223
|
+
client_id ||= SecureRandom.uuid
|
224
|
+
@logger.info("New client connection: #{client_id} (#{browser_type})")
|
225
|
+
end
|
226
|
+
|
227
|
+
client_id
|
228
|
+
end
|
229
|
+
|
230
|
+
# Detect browser type from user agent
|
231
|
+
def detect_browser_type(user_agent)
|
232
|
+
is_chrome = user_agent.include?('Chrome/')
|
233
|
+
is_safari = user_agent.include?('Safari/') && !user_agent.include?('Chrome/')
|
234
|
+
is_firefox = user_agent.include?('Firefox/')
|
235
|
+
is_node = user_agent.include?('Node.js') || user_agent.include?('node-fetch')
|
236
|
+
|
237
|
+
if is_chrome
|
238
|
+
'Chrome'
|
239
|
+
elsif is_safari
|
240
|
+
'Safari'
|
241
|
+
elsif is_firefox
|
242
|
+
'Firefox'
|
243
|
+
elsif is_node
|
244
|
+
'Node.js'
|
245
|
+
else
|
246
|
+
'Other browser'
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
# Check if client is MCP inspector
|
251
|
+
def mcp_inspector?(user_agent, env)
|
252
|
+
user_agent.include?('mcp-inspector') || (env['mcp.client_name'] == 'mcp-inspector')
|
253
|
+
end
|
254
|
+
|
255
|
+
# Handle client reconnection
|
256
|
+
def handle_client_reconnection(client_id, browser_type)
|
257
|
+
@logger.info("Client #{client_id} is reconnecting (#{browser_type})")
|
258
|
+
old_client = @sse_clients[client_id]
|
259
|
+
begin
|
260
|
+
old_client[:stream].close if old_client[:stream].respond_to?(:close) && !old_client[:stream].closed?
|
261
|
+
rescue StandardError => e
|
262
|
+
@logger.error("Error closing old connection for client #{client_id}: #{e.message}")
|
263
|
+
end
|
264
|
+
unregister_sse_client(client_id)
|
265
|
+
|
266
|
+
# Small delay to ensure the old connection is fully closed
|
267
|
+
sleep 0.1
|
268
|
+
end
|
269
|
+
|
270
|
+
# Handle SSE with Rack hijacking (e.g., Puma)
|
271
|
+
def handle_rack_hijack_sse(env, headers)
|
272
|
+
client_id = extract_client_id(env)
|
273
|
+
@logger.info("Setting up Rack hijack SSE connection for client #{client_id}")
|
274
|
+
|
275
|
+
env['rack.hijack'].call
|
276
|
+
io = env['rack.hijack_io']
|
277
|
+
@logger.info("Obtained hijack IO for client #{client_id}")
|
278
|
+
|
279
|
+
setup_sse_connection(client_id, io, headers)
|
280
|
+
start_keep_alive_thread(client_id, io)
|
281
|
+
|
282
|
+
# Return async response
|
283
|
+
[-1, {}, []]
|
284
|
+
end
|
285
|
+
|
286
|
+
# Set up the SSE connection
|
287
|
+
def setup_sse_connection(client_id, io, headers)
|
288
|
+
# Send headers
|
289
|
+
@logger.info("Sending HTTP headers for SSE connection #{client_id}")
|
290
|
+
io.write("HTTP/1.1 200 OK\r\n")
|
291
|
+
headers.each { |k, v| io.write("#{k}: #{v}\r\n") }
|
292
|
+
io.write("\r\n")
|
293
|
+
io.flush
|
294
|
+
|
295
|
+
# Register client
|
296
|
+
register_sse_client(client_id, io)
|
297
|
+
|
298
|
+
# Send an initial comment to keep the connection alive
|
299
|
+
io.write(": SSE connection established\n\n")
|
300
|
+
|
301
|
+
# Send endpoint information as the first message
|
302
|
+
endpoint = "#{@path_prefix}/messages"
|
303
|
+
@logger.info("Sending endpoint information to client #{client_id}: #{endpoint}")
|
304
|
+
io.write("event: endpoint\ndata: #{endpoint}\n\n")
|
305
|
+
|
306
|
+
# Send a retry directive with a very short reconnect time
|
307
|
+
# This helps browsers reconnect quickly if the connection is lost
|
308
|
+
io.write("retry: 100\n\n") # 100ms reconnect time
|
309
|
+
io.flush
|
310
|
+
rescue StandardError => e
|
311
|
+
@logger.error("Error setting up SSE connection for client #{client_id}: #{e.message}")
|
312
|
+
@logger.error(e.backtrace.join("\n")) if e.backtrace
|
313
|
+
raise
|
314
|
+
end
|
315
|
+
|
316
|
+
# Start a keep-alive thread for SSE connection
|
317
|
+
def start_keep_alive_thread(client_id, io)
|
318
|
+
@logger.info("Starting keep-alive thread for client #{client_id}")
|
319
|
+
Thread.new do
|
320
|
+
keep_alive_loop(io, client_id)
|
321
|
+
rescue StandardError => e
|
322
|
+
@logger.error("Error in SSE keep-alive for client #{client_id}: #{e.message}")
|
323
|
+
@logger.error(e.backtrace.join("\n")) if e.backtrace
|
324
|
+
ensure
|
325
|
+
cleanup_sse_connection(client_id, io)
|
326
|
+
end
|
327
|
+
end
|
328
|
+
|
329
|
+
# Run the keep-alive loop
|
330
|
+
def keep_alive_loop(io, client_id)
|
331
|
+
@logger.info("Starting keep-alive loop for SSE connection #{client_id}")
|
332
|
+
ping_count = 0
|
333
|
+
ping_interval = 1 # Send a ping every 1 second
|
334
|
+
max_ping_count = 30 # Reset connection after 30 pings (about 30 seconds)
|
335
|
+
|
336
|
+
while @running && !io.closed?
|
337
|
+
begin
|
338
|
+
ping_count = send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
|
339
|
+
break if ping_count >= max_ping_count
|
340
|
+
|
341
|
+
sleep ping_interval
|
342
|
+
rescue Errno::EPIPE, IOError => e
|
343
|
+
# Broken pipe or IO error - client disconnected
|
344
|
+
@logger.error("SSE connection error for client #{client_id}: #{e.message}")
|
345
|
+
break
|
346
|
+
end
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
350
|
+
# Send a keep-alive ping and return the updated ping count
|
351
|
+
def send_keep_alive_ping(io, client_id, ping_count, max_ping_count)
|
352
|
+
ping_count += 1
|
353
|
+
|
354
|
+
# Send a comment before each ping to keep the connection alive
|
355
|
+
io.write(": keep-alive #{ping_count}\n\n")
|
356
|
+
io.flush
|
357
|
+
|
358
|
+
# Only send actual ping events every 5 counts to reduce overhead
|
359
|
+
if (ping_count % 5).zero?
|
360
|
+
@logger.info("Sending ping ##{ping_count} to SSE client #{client_id}")
|
361
|
+
send_ping_event(io)
|
362
|
+
end
|
363
|
+
|
364
|
+
# If we've reached the max ping count, force a reconnection
|
365
|
+
if ping_count >= max_ping_count
|
366
|
+
@logger.info("Reached max ping count (#{max_ping_count}) for client #{client_id}, forcing reconnection")
|
367
|
+
send_reconnect_event(io)
|
368
|
+
end
|
369
|
+
|
370
|
+
ping_count
|
371
|
+
end
|
372
|
+
|
373
|
+
# Send a ping event
|
374
|
+
def send_ping_event(io)
|
375
|
+
ping_message = {
|
376
|
+
jsonrpc: '2.0',
|
377
|
+
method: 'ping',
|
378
|
+
id: SecureRandom.uuid
|
379
|
+
}
|
380
|
+
io.write("event: ping\ndata: #{JSON.generate(ping_message)}\n\n")
|
381
|
+
io.flush
|
382
|
+
end
|
383
|
+
|
384
|
+
# Send a reconnect event
|
385
|
+
def send_reconnect_event(io)
|
386
|
+
io.write("event: reconnect\ndata: {\"reason\":\"timeout prevention\"}\n\n")
|
387
|
+
io.flush
|
388
|
+
end
|
389
|
+
|
390
|
+
# Clean up SSE connection
|
391
|
+
def cleanup_sse_connection(client_id, io)
|
392
|
+
@logger.info("Cleaning up SSE connection for client #{client_id}")
|
393
|
+
unregister_sse_client(client_id)
|
394
|
+
begin
|
395
|
+
io.close unless io.closed?
|
396
|
+
@logger.info("Successfully closed IO for client #{client_id}")
|
397
|
+
rescue StandardError => e
|
398
|
+
@logger.error("Error closing IO for client #{client_id}: #{e.message}")
|
399
|
+
end
|
400
|
+
end
|
401
|
+
|
402
|
+
# Handle SSE with Rails ActionController::Live
|
403
|
+
def handle_rails_sse(env, headers)
|
404
|
+
client_id = extract_client_id(env)
|
405
|
+
controller = env['action_controller.instance']
|
406
|
+
stream = controller.response.stream
|
407
|
+
|
408
|
+
# Register client
|
409
|
+
register_sse_client(client_id, stream)
|
410
|
+
|
411
|
+
# The controller will handle the streaming
|
412
|
+
[200, headers, []]
|
413
|
+
end
|
414
|
+
|
415
|
+
# Handle message POST request
|
416
|
+
def handle_message_request(request)
|
417
|
+
@logger.info('Received message request')
|
418
|
+
return method_not_allowed_response unless request.post?
|
419
|
+
|
420
|
+
begin
|
421
|
+
process_json_request(request)
|
422
|
+
rescue JSON::ParserError => e
|
423
|
+
handle_parse_error(e)
|
424
|
+
rescue StandardError => e
|
425
|
+
handle_internal_error(e)
|
426
|
+
end
|
427
|
+
end
|
428
|
+
|
429
|
+
# Process a JSON-RPC request
|
430
|
+
def process_json_request(request)
|
431
|
+
# Parse the request body
|
432
|
+
body = request.body.read
|
433
|
+
|
434
|
+
response = process_message(body)
|
435
|
+
@logger.info("Response: #{response}")
|
436
|
+
[200, { 'Content-Type' => 'application/json' }, [response]]
|
437
|
+
end
|
438
|
+
|
439
|
+
# Return a method not allowed error response
|
440
|
+
def method_not_allowed_response
|
441
|
+
json_rpc_error_response(405, -32_601, 'Method not allowed')
|
442
|
+
end
|
443
|
+
|
444
|
+
# Handle JSON parse errors
|
445
|
+
def handle_parse_error(error)
|
446
|
+
@logger.error("Invalid JSON in request: #{error.message}")
|
447
|
+
json_rpc_error_response(400, -32_700, 'Parse error: Invalid JSON')
|
448
|
+
end
|
449
|
+
|
450
|
+
# Handle internal server errors
|
451
|
+
def handle_internal_error(error)
|
452
|
+
@logger.error("Error processing message: #{error.message}")
|
453
|
+
json_rpc_error_response(500, -32_603, "Internal error: #{error.message}")
|
454
|
+
end
|
455
|
+
|
456
|
+
def json_rpc_error_response(http_status, code, message, id = nil)
|
457
|
+
[http_status, { 'Content-Type' => 'application/json' },
|
458
|
+
[JSON.generate(
|
459
|
+
{
|
460
|
+
jsonrpc: '2.0',
|
461
|
+
error: { code: code, message: message },
|
462
|
+
id: id
|
463
|
+
}
|
464
|
+
)]]
|
465
|
+
end
|
466
|
+
end
|
467
|
+
end
|
468
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'base_transport'
|
4
|
+
|
5
|
+
module MCP
|
6
|
+
module Transports
|
7
|
+
# STDIO transport for MCP
|
8
|
+
# This transport uses standard input/output for communication
|
9
|
+
class StdioTransport < BaseTransport
|
10
|
+
def initialize(server, logger: nil)
|
11
|
+
super
|
12
|
+
@running = false
|
13
|
+
end
|
14
|
+
|
15
|
+
# Start the transport
|
16
|
+
def start
|
17
|
+
@logger.info('Starting STDIO transport')
|
18
|
+
@running = true
|
19
|
+
|
20
|
+
# Process input from stdin
|
21
|
+
while @running && (line = $stdin.gets)
|
22
|
+
begin
|
23
|
+
process_message(line.strip)
|
24
|
+
rescue StandardError => e
|
25
|
+
@logger.error("Error processing message: #{e.message}")
|
26
|
+
@logger.error(e.backtrace.join("\n"))
|
27
|
+
send_error(-32_000, "Internal error: #{e.message}")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# Stop the transport
|
33
|
+
def stop
|
34
|
+
@logger.info('Stopping STDIO transport')
|
35
|
+
@running = false
|
36
|
+
end
|
37
|
+
|
38
|
+
# Send a message to the client
|
39
|
+
def send_message(message)
|
40
|
+
json_message = message.is_a?(String) ? message : JSON.generate(message)
|
41
|
+
|
42
|
+
$stdout.puts(json_message)
|
43
|
+
$stdout.flush
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# Send a JSON-RPC error response
|
49
|
+
def send_error(code, message, id = nil)
|
50
|
+
response = {
|
51
|
+
jsonrpc: '2.0',
|
52
|
+
error: {
|
53
|
+
code: code,
|
54
|
+
message: message
|
55
|
+
},
|
56
|
+
id: id
|
57
|
+
}
|
58
|
+
send_message(response)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|