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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -3
  3. data/README.md +359 -163
  4. data/config/routes.rb +5 -0
  5. data/lib/generators/mcp_on_ruby/expose/expose_generator.rb +108 -0
  6. data/lib/generators/mcp_on_ruby/install/install_generator.rb +37 -0
  7. data/lib/generators/mcp_on_ruby/install/templates/initializer.rb +22 -0
  8. data/lib/mcp_on_ruby/call_context.rb +29 -0
  9. data/lib/mcp_on_ruby/configuration.rb +63 -59
  10. data/lib/mcp_on_ruby/controller.rb +60 -0
  11. data/lib/mcp_on_ruby/declaration.rb +24 -0
  12. data/lib/mcp_on_ruby/dispatcher.rb +97 -0
  13. data/lib/mcp_on_ruby/doctor.rb +67 -0
  14. data/lib/mcp_on_ruby/endpoint.rb +43 -0
  15. data/lib/mcp_on_ruby/engine.rb +29 -0
  16. data/lib/mcp_on_ruby/registry.rb +146 -0
  17. data/lib/mcp_on_ruby/result.rb +83 -0
  18. data/lib/mcp_on_ruby/rspec.rb +8 -0
  19. data/lib/mcp_on_ruby/schema.rb +149 -0
  20. data/lib/mcp_on_ruby/server_factory.rb +66 -0
  21. data/lib/mcp_on_ruby/stdio.rb +42 -0
  22. data/lib/mcp_on_ruby/test_helper.rb +86 -0
  23. data/lib/mcp_on_ruby/tool_spec.rb +87 -0
  24. data/lib/mcp_on_ruby/tool_table.rb +31 -0
  25. data/lib/mcp_on_ruby/version.rb +2 -3
  26. data/lib/mcp_on_ruby/warden_hook.rb +21 -0
  27. data/lib/mcp_on_ruby.rb +44 -122
  28. data/lib/tasks/mcp_on_ruby.rake +20 -0
  29. metadata +65 -136
  30. data/CODE_OF_CONDUCT.md +0 -56
  31. data/CONTRIBUTING.md +0 -86
  32. data/bin/console +0 -11
  33. data/bin/setup +0 -6
  34. data/docs/advanced-usage.md +0 -132
  35. data/docs/api-reference.md +0 -35
  36. data/docs/testing.md +0 -55
  37. data/examples/claude/README.md +0 -171
  38. data/examples/claude/claude-bridge.js +0 -122
  39. data/lib/mcp_on_ruby/errors.rb +0 -137
  40. data/lib/mcp_on_ruby/generators/install_generator.rb +0 -46
  41. data/lib/mcp_on_ruby/generators/resource_generator.rb +0 -63
  42. data/lib/mcp_on_ruby/generators/templates/README +0 -31
  43. data/lib/mcp_on_ruby/generators/templates/application_resource.rb +0 -20
  44. data/lib/mcp_on_ruby/generators/templates/application_tool.rb +0 -18
  45. data/lib/mcp_on_ruby/generators/templates/initializer.rb +0 -41
  46. data/lib/mcp_on_ruby/generators/templates/resource.rb +0 -50
  47. data/lib/mcp_on_ruby/generators/templates/resource_spec.rb +0 -67
  48. data/lib/mcp_on_ruby/generators/templates/sample_resource.rb +0 -57
  49. data/lib/mcp_on_ruby/generators/templates/sample_tool.rb +0 -59
  50. data/lib/mcp_on_ruby/generators/templates/tool.rb +0 -38
  51. data/lib/mcp_on_ruby/generators/templates/tool_spec.rb +0 -55
  52. data/lib/mcp_on_ruby/generators/tool_generator.rb +0 -51
  53. data/lib/mcp_on_ruby/railtie.rb +0 -108
  54. data/lib/mcp_on_ruby/resource.rb +0 -161
  55. data/lib/mcp_on_ruby/server.rb +0 -378
  56. data/lib/mcp_on_ruby/tool.rb +0 -134
  57. 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