mcp_on_ruby 0.3.0 → 1.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -28
  3. data/CODE_OF_CONDUCT.md +30 -58
  4. data/CONTRIBUTING.md +61 -67
  5. data/LICENSE.txt +2 -2
  6. data/README.md +159 -509
  7. data/bin/console +11 -0
  8. data/bin/setup +6 -0
  9. data/docs/advanced-usage.md +132 -0
  10. data/docs/api-reference.md +35 -0
  11. data/docs/testing.md +55 -0
  12. data/examples/claude/README.md +171 -0
  13. data/examples/claude/claude-bridge.js +122 -0
  14. data/lib/mcp_on_ruby/configuration.rb +74 -0
  15. data/lib/mcp_on_ruby/errors.rb +137 -0
  16. data/lib/mcp_on_ruby/generators/install_generator.rb +46 -0
  17. data/lib/mcp_on_ruby/generators/resource_generator.rb +63 -0
  18. data/lib/mcp_on_ruby/generators/templates/README +31 -0
  19. data/lib/mcp_on_ruby/generators/templates/application_resource.rb +20 -0
  20. data/lib/mcp_on_ruby/generators/templates/application_tool.rb +18 -0
  21. data/lib/mcp_on_ruby/generators/templates/initializer.rb +41 -0
  22. data/lib/mcp_on_ruby/generators/templates/resource.rb +50 -0
  23. data/lib/mcp_on_ruby/generators/templates/resource_spec.rb +67 -0
  24. data/lib/mcp_on_ruby/generators/templates/sample_resource.rb +57 -0
  25. data/lib/mcp_on_ruby/generators/templates/sample_tool.rb +59 -0
  26. data/lib/mcp_on_ruby/generators/templates/tool.rb +38 -0
  27. data/lib/mcp_on_ruby/generators/templates/tool_spec.rb +55 -0
  28. data/lib/mcp_on_ruby/generators/tool_generator.rb +51 -0
  29. data/lib/mcp_on_ruby/railtie.rb +108 -0
  30. data/lib/mcp_on_ruby/resource.rb +161 -0
  31. data/lib/mcp_on_ruby/server.rb +378 -0
  32. data/lib/mcp_on_ruby/tool.rb +134 -0
  33. data/lib/mcp_on_ruby/transport.rb +330 -0
  34. data/lib/mcp_on_ruby/version.rb +6 -0
  35. data/lib/mcp_on_ruby.rb +142 -0
  36. metadata +62 -173
  37. data/lib/ruby_mcp/client.rb +0 -43
  38. data/lib/ruby_mcp/configuration.rb +0 -90
  39. data/lib/ruby_mcp/errors.rb +0 -17
  40. data/lib/ruby_mcp/models/context.rb +0 -52
  41. data/lib/ruby_mcp/models/engine.rb +0 -31
  42. data/lib/ruby_mcp/models/message.rb +0 -60
  43. data/lib/ruby_mcp/providers/anthropic.rb +0 -269
  44. data/lib/ruby_mcp/providers/base.rb +0 -57
  45. data/lib/ruby_mcp/providers/openai.rb +0 -265
  46. data/lib/ruby_mcp/schemas.rb +0 -56
  47. data/lib/ruby_mcp/server/app.rb +0 -84
  48. data/lib/ruby_mcp/server/base_controller.rb +0 -49
  49. data/lib/ruby_mcp/server/content_controller.rb +0 -68
  50. data/lib/ruby_mcp/server/contexts_controller.rb +0 -67
  51. data/lib/ruby_mcp/server/controller.rb +0 -29
  52. data/lib/ruby_mcp/server/engines_controller.rb +0 -34
  53. data/lib/ruby_mcp/server/generate_controller.rb +0 -140
  54. data/lib/ruby_mcp/server/messages_controller.rb +0 -30
  55. data/lib/ruby_mcp/server/router.rb +0 -84
  56. data/lib/ruby_mcp/storage/active_record.rb +0 -414
  57. data/lib/ruby_mcp/storage/base.rb +0 -43
  58. data/lib/ruby_mcp/storage/error.rb +0 -8
  59. data/lib/ruby_mcp/storage/memory.rb +0 -69
  60. data/lib/ruby_mcp/storage/redis.rb +0 -197
  61. data/lib/ruby_mcp/storage_factory.rb +0 -43
  62. data/lib/ruby_mcp/validator.rb +0 -45
  63. data/lib/ruby_mcp/version.rb +0 -6
  64. data/lib/ruby_mcp.rb +0 -71
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ # Base class for MCP tools - functions that AI can execute
5
+ class Tool
6
+ attr_reader :name, :description, :input_schema, :metadata, :tags
7
+
8
+ # Create a new tool
9
+ # @param name [String] The tool name
10
+ # @param description [String] The tool description
11
+ # @param input_schema [Hash] JSON Schema for input validation
12
+ # @param metadata [Hash] Additional metadata
13
+ # @param tags [Array<String>] Tags for categorization
14
+ def initialize(name:, description: '', input_schema: {}, metadata: {}, tags: [])
15
+ @name = name.to_s
16
+ @description = description
17
+ @input_schema = normalize_schema(input_schema)
18
+ @metadata = metadata
19
+ @tags = Array(tags)
20
+ end
21
+
22
+ # Execute the tool with given arguments
23
+ # @param arguments [Hash] The arguments to pass to the tool
24
+ # @param context [Hash] Request context (headers, user info, etc.)
25
+ # @return [Hash] The tool execution result
26
+ def call(arguments = {}, context = {})
27
+ # Validate arguments against schema
28
+ validate_arguments!(arguments)
29
+
30
+ # Call the implementation
31
+ execute(arguments, context)
32
+ rescue => error
33
+ McpOnRuby.logger.error("Tool '#{name}' execution failed: #{error.message}")
34
+ McpOnRuby.logger.error(error.backtrace.join("\n"))
35
+
36
+ {
37
+ error: {
38
+ code: -32603,
39
+ message: "Tool execution failed: #{error.message}",
40
+ data: { tool: name, error_type: error.class.name }
41
+ }
42
+ }
43
+ end
44
+
45
+ # Get the tool's schema for MCP protocol
46
+ # @return [Hash] The tool schema
47
+ def to_schema
48
+ {
49
+ name: name,
50
+ description: description,
51
+ inputSchema: input_schema
52
+ }.tap do |schema|
53
+ schema[:metadata] = metadata unless metadata.empty?
54
+ schema[:tags] = tags unless tags.empty?
55
+ end
56
+ end
57
+
58
+ # Check if tool is authorized for the given context
59
+ # @param context [Hash] Request context
60
+ # @return [Boolean] True if authorized
61
+ def authorized?(context = {})
62
+ return true unless respond_to?(:authorize, true)
63
+
64
+ authorize(context)
65
+ rescue => error
66
+ McpOnRuby.logger.warn("Authorization check failed for tool '#{name}': #{error.message}")
67
+ false
68
+ end
69
+
70
+ protected
71
+
72
+ # Override this method to implement tool functionality
73
+ # @param arguments [Hash] Validated arguments
74
+ # @param context [Hash] Request context
75
+ # @return [Hash] Tool result
76
+ def execute(arguments, context)
77
+ raise NotImplementedError, "Tool '#{name}' must implement #execute method"
78
+ end
79
+
80
+ # Override this method to implement authorization logic
81
+ # @param context [Hash] Request context
82
+ # @return [Boolean] True if authorized
83
+ def authorize(context)
84
+ true
85
+ end
86
+
87
+ private
88
+
89
+ # Validate arguments against the input schema
90
+ # @param arguments [Hash] Arguments to validate
91
+ # @raise [McpOnRuby::ValidationError] If validation fails
92
+ def validate_arguments!(arguments)
93
+ return if input_schema.empty?
94
+
95
+ errors = JSON::Validator.fully_validate(input_schema, arguments)
96
+ return if errors.empty?
97
+
98
+ raise McpOnRuby::ValidationError, "Tool '#{name}' validation failed: #{errors.join(', ')}"
99
+ end
100
+
101
+ # Normalize schema to ensure it's a proper JSON Schema
102
+ # @param schema [Hash] Input schema
103
+ # @return [Hash] Normalized schema
104
+ def normalize_schema(schema)
105
+ return {} if schema.nil? || schema.empty?
106
+
107
+ # Ensure we have a proper JSON Schema structure
108
+ normalized = schema.is_a?(Hash) ? schema.dup : {}
109
+
110
+ # Set default type if not specified
111
+ normalized['type'] ||= 'object' if normalized.key?('properties') || normalized.key?('required')
112
+
113
+ # Convert symbol keys to strings for JSON Schema compatibility
114
+ deep_stringify_keys(normalized)
115
+ end
116
+
117
+ # Convert all symbol keys to strings recursively
118
+ # @param obj [Object] Object to convert
119
+ # @return [Object] Object with string keys
120
+ def deep_stringify_keys(obj)
121
+ case obj
122
+ when Hash
123
+ obj.each_with_object({}) do |(key, value), hash|
124
+ hash[key.to_s] = deep_stringify_keys(value)
125
+ end
126
+ when Array
127
+ obj.map { |item| deep_stringify_keys(item) }
128
+ else
129
+ obj
130
+ end
131
+ end
132
+ end
133
+
134
+ end
@@ -0,0 +1,330 @@
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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ VERSION = "1.0.0"
5
+ PROTOCOL_VERSION = "2024-11-05"
6
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+ require 'json'
5
+ require 'json-schema'
6
+ require 'rack'
7
+ require 'webrick'
8
+
9
+ require_relative 'mcp_on_ruby/version'
10
+ require_relative 'mcp_on_ruby/errors'
11
+ require_relative 'mcp_on_ruby/configuration'
12
+ require_relative 'mcp_on_ruby/server'
13
+ require_relative 'mcp_on_ruby/tool'
14
+ require_relative 'mcp_on_ruby/resource'
15
+ require_relative 'mcp_on_ruby/transport'
16
+
17
+ # Rails integration if available
18
+ if defined?(Rails)
19
+ require_relative 'mcp_on_ruby/railtie'
20
+ end
21
+
22
+ # Production-ready Model Context Protocol implementation for Rails
23
+ module McpOnRuby
24
+ class << self
25
+ attr_writer :logger
26
+ attr_accessor :configuration
27
+
28
+ # Configure the library
29
+ # @yield [Configuration] The configuration object
30
+ # @return [Configuration] The configuration object
31
+ def configure
32
+ self.configuration ||= Configuration.new
33
+ yield(configuration) if block_given?
34
+ configuration
35
+ end
36
+
37
+ # Get the logger
38
+ # @return [Logger] The logger
39
+ def logger
40
+ @logger ||= Logger.new($stdout).tap do |log|
41
+ log.progname = 'McpOnRuby'
42
+ log.level = configuration&.log_level || Logger::INFO
43
+ end
44
+ end
45
+
46
+ # Create a new MCP server
47
+ # @param options [Hash] Server configuration options
48
+ # @yield [Server] The server instance for configuration
49
+ # @return [Server] The configured server
50
+ def server(options = {}, &block)
51
+ Server.new(options, &block)
52
+ end
53
+
54
+ # Create a tool with DSL
55
+ # @param name [String] Tool name
56
+ # @param description [String] Tool description
57
+ # @param input_schema [Hash] JSON Schema for validation
58
+ # @param options [Hash] Additional options (metadata, tags)
59
+ # @param block [Proc] Tool implementation
60
+ # @return [Tool] The created tool
61
+ def tool(name, description = '', input_schema = {}, **options, &block)
62
+ raise ArgumentError, 'Tool implementation block is required' unless block_given?
63
+
64
+ Class.new(Tool) do
65
+ define_method :execute do |arguments, context|
66
+ case block.arity
67
+ when 0
68
+ instance_exec(&block)
69
+ when 1
70
+ instance_exec(arguments, &block)
71
+ else
72
+ instance_exec(arguments, context, &block)
73
+ end
74
+ end
75
+ end.new(
76
+ name: name,
77
+ description: description,
78
+ input_schema: input_schema,
79
+ metadata: options[:metadata] || {},
80
+ tags: options[:tags] || []
81
+ )
82
+ end
83
+
84
+ # Create a resource with DSL
85
+ # @param uri [String] Resource URI
86
+ # @param options [Hash] Resource options (name, description, etc.)
87
+ # @param block [Proc] Resource implementation
88
+ # @return [Resource] The created resource
89
+ def resource(uri, **options, &block)
90
+ raise ArgumentError, 'Resource implementation block is required' unless block_given?
91
+
92
+ Class.new(Resource) do
93
+ define_method :fetch_content do |params, context|
94
+ case block.arity
95
+ when 0
96
+ instance_exec(&block)
97
+ when 1
98
+ instance_exec(params, &block)
99
+ else
100
+ instance_exec(params, context, &block)
101
+ end
102
+ end
103
+ end.new(
104
+ uri: uri,
105
+ name: options[:name],
106
+ description: options[:description] || '',
107
+ mime_type: options[:mime_type] || 'application/json',
108
+ metadata: options[:metadata] || {},
109
+ tags: options[:tags] || []
110
+ )
111
+ end
112
+
113
+ # Broadcast resource update to connected SSE clients
114
+ # @param uri [String] Resource URI that was updated
115
+ # @param event_type [String] Type of event
116
+ def broadcast_resource_update(uri, event_type = 'resource_updated')
117
+ # This would be implemented when SSE is fully integrated
118
+ logger.info("Resource update broadcasted: #{uri} (#{event_type})")
119
+ end
120
+
121
+ # Mount MCP server in Rails application
122
+ # @param app [Rails::Application] The Rails application
123
+ # @param options [Hash] Mounting options
124
+ # @yield [Server] The server instance for configuration
125
+ # @return [Server] The mounted server
126
+ def mount_in_rails(app, **options, &block)
127
+ server = self.server(options, &block)
128
+
129
+ # Mount the transport middleware
130
+ app.config.middleware.use(
131
+ Transport::RackMiddleware,
132
+ server: server,
133
+ **options
134
+ )
135
+
136
+ server
137
+ end
138
+ end
139
+ end
140
+
141
+ # Alias for convenience
142
+ MCP = McpOnRuby