mcp_on_ruby 1.0.0 → 2.0.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 +39 -3
- data/README.md +359 -163
- data/config/routes.rb +5 -0
- data/lib/generators/mcp_on_ruby/expose/expose_generator.rb +108 -0
- data/lib/generators/mcp_on_ruby/install/install_generator.rb +37 -0
- data/lib/generators/mcp_on_ruby/install/templates/initializer.rb +22 -0
- data/lib/mcp_on_ruby/call_context.rb +29 -0
- data/lib/mcp_on_ruby/configuration.rb +63 -59
- data/lib/mcp_on_ruby/controller.rb +60 -0
- data/lib/mcp_on_ruby/declaration.rb +24 -0
- data/lib/mcp_on_ruby/dispatcher.rb +97 -0
- data/lib/mcp_on_ruby/doctor.rb +67 -0
- data/lib/mcp_on_ruby/endpoint.rb +43 -0
- data/lib/mcp_on_ruby/engine.rb +29 -0
- data/lib/mcp_on_ruby/registry.rb +146 -0
- data/lib/mcp_on_ruby/result.rb +83 -0
- data/lib/mcp_on_ruby/rspec.rb +8 -0
- data/lib/mcp_on_ruby/schema.rb +149 -0
- data/lib/mcp_on_ruby/server_factory.rb +66 -0
- data/lib/mcp_on_ruby/stdio.rb +42 -0
- data/lib/mcp_on_ruby/test_helper.rb +86 -0
- data/lib/mcp_on_ruby/tool_spec.rb +87 -0
- data/lib/mcp_on_ruby/tool_table.rb +31 -0
- data/lib/mcp_on_ruby/version.rb +2 -3
- data/lib/mcp_on_ruby/warden_hook.rb +21 -0
- data/lib/mcp_on_ruby.rb +44 -122
- data/lib/tasks/mcp_on_ruby.rake +20 -0
- metadata +65 -136
- data/CODE_OF_CONDUCT.md +0 -56
- data/CONTRIBUTING.md +0 -86
- data/bin/console +0 -11
- data/bin/setup +0 -6
- data/docs/advanced-usage.md +0 -132
- data/docs/api-reference.md +0 -35
- data/docs/testing.md +0 -55
- data/examples/claude/README.md +0 -171
- data/examples/claude/claude-bridge.js +0 -122
- data/lib/mcp_on_ruby/errors.rb +0 -137
- data/lib/mcp_on_ruby/generators/install_generator.rb +0 -46
- data/lib/mcp_on_ruby/generators/resource_generator.rb +0 -63
- data/lib/mcp_on_ruby/generators/templates/README +0 -31
- data/lib/mcp_on_ruby/generators/templates/application_resource.rb +0 -20
- data/lib/mcp_on_ruby/generators/templates/application_tool.rb +0 -18
- data/lib/mcp_on_ruby/generators/templates/initializer.rb +0 -41
- data/lib/mcp_on_ruby/generators/templates/resource.rb +0 -50
- data/lib/mcp_on_ruby/generators/templates/resource_spec.rb +0 -67
- data/lib/mcp_on_ruby/generators/templates/sample_resource.rb +0 -57
- data/lib/mcp_on_ruby/generators/templates/sample_tool.rb +0 -59
- data/lib/mcp_on_ruby/generators/templates/tool.rb +0 -38
- data/lib/mcp_on_ruby/generators/templates/tool_spec.rb +0 -55
- data/lib/mcp_on_ruby/generators/tool_generator.rb +0 -51
- data/lib/mcp_on_ruby/railtie.rb +0 -108
- data/lib/mcp_on_ruby/resource.rb +0 -161
- data/lib/mcp_on_ruby/server.rb +0 -378
- data/lib/mcp_on_ruby/tool.rb +0 -134
- data/lib/mcp_on_ruby/transport.rb +0 -330
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module McpOnRuby
|
|
4
|
-
module Transport
|
|
5
|
-
# Rack middleware for handling MCP requests
|
|
6
|
-
class RackMiddleware
|
|
7
|
-
def initialize(app, server:, **options)
|
|
8
|
-
@app = app
|
|
9
|
-
@server = server
|
|
10
|
-
@path = options[:path] || @server.configuration.path
|
|
11
|
-
@logger = McpOnRuby.logger
|
|
12
|
-
@options = options
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def call(env)
|
|
16
|
-
request = Rack::Request.new(env)
|
|
17
|
-
|
|
18
|
-
# Only handle requests to our MCP path
|
|
19
|
-
return @app.call(env) unless request.path == @path
|
|
20
|
-
|
|
21
|
-
# Only handle POST requests for JSON-RPC
|
|
22
|
-
return method_not_allowed_response unless request.post?
|
|
23
|
-
|
|
24
|
-
# Security checks
|
|
25
|
-
security_result = check_security(request)
|
|
26
|
-
return security_result if security_result
|
|
27
|
-
|
|
28
|
-
# Handle MCP request
|
|
29
|
-
handle_mcp_request(request)
|
|
30
|
-
|
|
31
|
-
rescue => error
|
|
32
|
-
@logger.error("Transport error: #{error.message}")
|
|
33
|
-
@logger.error(error.backtrace.join("\n"))
|
|
34
|
-
internal_error_response
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
private
|
|
38
|
-
|
|
39
|
-
def handle_mcp_request(request)
|
|
40
|
-
# Build request context
|
|
41
|
-
context = build_request_context(request)
|
|
42
|
-
|
|
43
|
-
# Read request body
|
|
44
|
-
request_body = request.body.read
|
|
45
|
-
|
|
46
|
-
# Handle the request through server
|
|
47
|
-
response_json = @server.handle_request(request_body, context)
|
|
48
|
-
|
|
49
|
-
if response_json
|
|
50
|
-
# JSON-RPC response
|
|
51
|
-
json_response(response_json)
|
|
52
|
-
else
|
|
53
|
-
# Notification (no response)
|
|
54
|
-
[204, cors_headers, []]
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def build_request_context(request)
|
|
59
|
-
{
|
|
60
|
-
remote_ip: request.ip,
|
|
61
|
-
user_agent: request.user_agent,
|
|
62
|
-
headers: request.env.select { |k, _| k.start_with?('HTTP_') },
|
|
63
|
-
authenticated: authenticated?(request),
|
|
64
|
-
auth_token: extract_auth_token(request)
|
|
65
|
-
}
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def check_security(request)
|
|
69
|
-
config = @server.configuration
|
|
70
|
-
|
|
71
|
-
# DNS rebinding protection
|
|
72
|
-
if config.dns_rebinding_protection
|
|
73
|
-
origin = request.get_header('HTTP_ORIGIN')
|
|
74
|
-
host = request.get_header('HTTP_HOST')
|
|
75
|
-
|
|
76
|
-
if origin && !origin_allowed?(origin, host)
|
|
77
|
-
return forbidden_response("Origin not allowed: #{origin}")
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Authentication check
|
|
82
|
-
if config.authentication_required
|
|
83
|
-
unless authenticated?(request)
|
|
84
|
-
return unauthorized_response
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Localhost only check
|
|
89
|
-
if config.localhost_only
|
|
90
|
-
unless localhost_request?(request)
|
|
91
|
-
return forbidden_response("Localhost only mode enabled")
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
nil # No security issues
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def origin_allowed?(origin, host)
|
|
99
|
-
config = @server.configuration
|
|
100
|
-
|
|
101
|
-
# Check localhost patterns if localhost_only is enabled
|
|
102
|
-
return false if config.localhost_only && !config.localhost_allowed?(origin)
|
|
103
|
-
|
|
104
|
-
# Check configured allowed origins
|
|
105
|
-
return config.origin_allowed?(origin) unless config.allowed_origins.empty?
|
|
106
|
-
|
|
107
|
-
# Default: allow same origin
|
|
108
|
-
origin_host = URI.parse(origin).host rescue nil
|
|
109
|
-
origin_host == host
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def authenticated?(request)
|
|
113
|
-
return true unless @server.configuration.authentication_required
|
|
114
|
-
|
|
115
|
-
token = extract_auth_token(request)
|
|
116
|
-
token && token == @server.configuration.authentication_token
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def extract_auth_token(request)
|
|
120
|
-
auth_header = request.get_header('HTTP_AUTHORIZATION')
|
|
121
|
-
return nil unless auth_header
|
|
122
|
-
|
|
123
|
-
# Support Bearer token format
|
|
124
|
-
if auth_header.start_with?('Bearer ')
|
|
125
|
-
auth_header[7..-1]
|
|
126
|
-
else
|
|
127
|
-
auth_header
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
def localhost_request?(request)
|
|
132
|
-
ip = request.ip
|
|
133
|
-
%w[127.0.0.1 ::1 localhost].include?(ip) || ip.start_with?('127.')
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
def cors_headers
|
|
137
|
-
headers = {}
|
|
138
|
-
|
|
139
|
-
if @server.configuration.cors_enabled
|
|
140
|
-
headers['Access-Control-Allow-Origin'] = '*'
|
|
141
|
-
headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
|
|
142
|
-
headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization'
|
|
143
|
-
headers['Access-Control-Max-Age'] = '86400'
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
headers['Content-Type'] = 'application/json'
|
|
147
|
-
headers
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def json_response(json_body)
|
|
151
|
-
[200, cors_headers, [json_body]]
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def method_not_allowed_response
|
|
155
|
-
[405, cors_headers.merge('Allow' => 'POST'), ['{"error": "Method not allowed"}']]
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
def unauthorized_response
|
|
159
|
-
error_body = {
|
|
160
|
-
jsonrpc: "2.0",
|
|
161
|
-
error: { code: -32600, message: "Unauthorized" },
|
|
162
|
-
id: nil
|
|
163
|
-
}
|
|
164
|
-
[401, cors_headers, [JSON.generate(error_body)]]
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def forbidden_response(message = "Forbidden")
|
|
168
|
-
error_body = {
|
|
169
|
-
jsonrpc: "2.0",
|
|
170
|
-
error: { code: -32600, message: message },
|
|
171
|
-
id: nil
|
|
172
|
-
}
|
|
173
|
-
[403, cors_headers, [JSON.generate(error_body)]]
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def internal_error_response
|
|
177
|
-
error_body = {
|
|
178
|
-
jsonrpc: "2.0",
|
|
179
|
-
error: { code: -32603, message: "Internal error" },
|
|
180
|
-
id: nil
|
|
181
|
-
}
|
|
182
|
-
[500, cors_headers, [JSON.generate(error_body)]]
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
# SSE (Server-Sent Events) transport for real-time updates
|
|
187
|
-
class SSETransport
|
|
188
|
-
def initialize(server, **options)
|
|
189
|
-
@server = server
|
|
190
|
-
@options = options
|
|
191
|
-
@logger = McpOnRuby.logger
|
|
192
|
-
@clients = {}
|
|
193
|
-
@mutex = Mutex.new
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def call(env)
|
|
197
|
-
request = Rack::Request.new(env)
|
|
198
|
-
|
|
199
|
-
# Only handle GET requests for SSE
|
|
200
|
-
return [405, {}, ['Method not allowed']] unless request.get?
|
|
201
|
-
|
|
202
|
-
# Security checks (reuse from RackMiddleware)
|
|
203
|
-
# ... security implementation similar to RackMiddleware
|
|
204
|
-
|
|
205
|
-
# Handle SSE connection
|
|
206
|
-
handle_sse_connection(env, request)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
private
|
|
210
|
-
|
|
211
|
-
def handle_sse_connection(env, request)
|
|
212
|
-
# Hijack the connection for SSE
|
|
213
|
-
if env['rack.hijack?']
|
|
214
|
-
env['rack.hijack'].call
|
|
215
|
-
io = env['rack.hijack_io']
|
|
216
|
-
|
|
217
|
-
# Send SSE headers
|
|
218
|
-
io.write("HTTP/1.1 200 OK\r\n")
|
|
219
|
-
io.write("Content-Type: text/event-stream\r\n")
|
|
220
|
-
io.write("Cache-Control: no-cache\r\n")
|
|
221
|
-
io.write("Connection: keep-alive\r\n")
|
|
222
|
-
io.write("\r\n")
|
|
223
|
-
|
|
224
|
-
# Register client
|
|
225
|
-
client_id = SecureRandom.uuid
|
|
226
|
-
register_client(client_id, io, request)
|
|
227
|
-
|
|
228
|
-
# Keep connection alive
|
|
229
|
-
keep_alive_loop(client_id, io)
|
|
230
|
-
else
|
|
231
|
-
[500, {}, ['SSE not supported - server must support connection hijacking']]
|
|
232
|
-
end
|
|
233
|
-
end
|
|
234
|
-
|
|
235
|
-
def register_client(client_id, io, request)
|
|
236
|
-
@mutex.synchronize do
|
|
237
|
-
@clients[client_id] = {
|
|
238
|
-
io: io,
|
|
239
|
-
context: build_request_context(request),
|
|
240
|
-
last_seen: Time.now
|
|
241
|
-
}
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
@logger.info("SSE client connected: #{client_id}")
|
|
245
|
-
|
|
246
|
-
# Send welcome message
|
|
247
|
-
send_event(client_id, 'connected', { clientId: client_id })
|
|
248
|
-
end
|
|
249
|
-
|
|
250
|
-
def keep_alive_loop(client_id, io)
|
|
251
|
-
loop do
|
|
252
|
-
begin
|
|
253
|
-
# Send periodic ping
|
|
254
|
-
send_event(client_id, 'ping', { timestamp: Time.now.iso8601 })
|
|
255
|
-
sleep(30)
|
|
256
|
-
|
|
257
|
-
# Check if client is still connected
|
|
258
|
-
break unless client_connected?(client_id)
|
|
259
|
-
rescue => error
|
|
260
|
-
@logger.warn("SSE client #{client_id} disconnected: #{error.message}")
|
|
261
|
-
break
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
ensure
|
|
265
|
-
unregister_client(client_id)
|
|
266
|
-
end
|
|
267
|
-
|
|
268
|
-
def send_event(client_id, event_type, data)
|
|
269
|
-
client = nil
|
|
270
|
-
@mutex.synchronize { client = @clients[client_id] }
|
|
271
|
-
return unless client
|
|
272
|
-
|
|
273
|
-
begin
|
|
274
|
-
io = client[:io]
|
|
275
|
-
io.write("event: #{event_type}\n")
|
|
276
|
-
io.write("data: #{JSON.generate(data)}\n")
|
|
277
|
-
io.write("\n")
|
|
278
|
-
io.flush
|
|
279
|
-
|
|
280
|
-
# Update last seen
|
|
281
|
-
@mutex.synchronize { @clients[client_id][:last_seen] = Time.now }
|
|
282
|
-
rescue => error
|
|
283
|
-
@logger.warn("Failed to send SSE event to #{client_id}: #{error.message}")
|
|
284
|
-
unregister_client(client_id)
|
|
285
|
-
end
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def client_connected?(client_id)
|
|
289
|
-
@mutex.synchronize { @clients.key?(client_id) }
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
def unregister_client(client_id)
|
|
293
|
-
@mutex.synchronize do
|
|
294
|
-
client = @clients.delete(client_id)
|
|
295
|
-
if client
|
|
296
|
-
begin
|
|
297
|
-
client[:io].close
|
|
298
|
-
rescue => error
|
|
299
|
-
@logger.debug("Error closing SSE client connection: #{error.message}")
|
|
300
|
-
end
|
|
301
|
-
end
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
@logger.info("SSE client disconnected: #{client_id}")
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
def build_request_context(request)
|
|
308
|
-
{
|
|
309
|
-
remote_ip: request.ip,
|
|
310
|
-
user_agent: request.user_agent,
|
|
311
|
-
headers: request.env.select { |k, _| k.start_with?('HTTP_') }
|
|
312
|
-
}
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
# Broadcast resource update to all connected clients
|
|
316
|
-
def broadcast_resource_update(uri, event_type = 'resource_updated')
|
|
317
|
-
return if @clients.empty?
|
|
318
|
-
|
|
319
|
-
data = {
|
|
320
|
-
uri: uri,
|
|
321
|
-
timestamp: Time.now.iso8601
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
@clients.keys.each do |client_id|
|
|
325
|
-
send_event(client_id, event_type, data)
|
|
326
|
-
end
|
|
327
|
-
end
|
|
328
|
-
end
|
|
329
|
-
end
|
|
330
|
-
end
|