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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+
5
+ module McpOnRuby
6
+ module Generators
7
+ # Generator for creating MCP tools
8
+ class ToolGenerator < Rails::Generators::NamedBase
9
+ source_root File.expand_path('templates', __dir__)
10
+
11
+ desc "Generate an MCP tool class"
12
+
13
+ argument :name, type: :string, desc: "Name of the tool"
14
+
15
+ class_option :description, type: :string, desc: "Description of the tool"
16
+ class_option :input_schema, type: :hash, default: {}, desc: "JSON Schema for input validation"
17
+
18
+ def create_tool_file
19
+ template 'tool.rb', File.join('app/tools', "#{file_name}_tool.rb")
20
+ end
21
+
22
+ def create_spec_file
23
+ return unless File.exist?(Rails.root.join('spec'))
24
+
25
+ template 'tool_spec.rb', File.join('spec/tools', "#{file_name}_tool_spec.rb")
26
+ end
27
+
28
+ private
29
+
30
+ def tool_name
31
+ name.underscore
32
+ end
33
+
34
+ def tool_class_name
35
+ "#{name.camelize}Tool"
36
+ end
37
+
38
+ def tool_description
39
+ options[:description] || "#{name.humanize} tool"
40
+ end
41
+
42
+ def input_schema_code
43
+ if options[:input_schema].any?
44
+ "input_schema #{options[:input_schema].inspect}"
45
+ else
46
+ "# input_schema({\n # type: 'object',\n # properties: {\n # param: { type: 'string' }\n # },\n # required: ['param']\n # })"
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ if defined?(Rails)
4
+ module McpOnRuby
5
+ # Rails integration via Railtie
6
+ class Railtie < Rails::Railtie
7
+ # Add MCP-specific directories to Rails autoload paths
8
+ initializer "mcp_on_ruby.setup_autoload_paths" do |app|
9
+ # Add app/tools and app/resources to autoload paths
10
+ %w[tools resources].each do |dir|
11
+ path = app.root.join("app", dir)
12
+ if path.exist? && !app.config.autoload_paths.include?(path.to_s)
13
+ app.config.autoload_paths = app.config.autoload_paths.dup if app.config.autoload_paths.frozen?
14
+ app.config.autoload_paths << path.to_s
15
+ end
16
+ end
17
+ end
18
+
19
+ # Set up Rails-friendly aliases
20
+ initializer "mcp_on_ruby.setup_aliases" do
21
+ # Create Rails-style base classes
22
+ Object.const_set('ApplicationTool', Class.new(McpOnRuby::Tool)) unless defined?(ApplicationTool)
23
+ Object.const_set('ApplicationResource', Class.new(McpOnRuby::Resource)) unless defined?(ApplicationResource)
24
+ end
25
+
26
+ # Configure MCP server if needed
27
+ initializer "mcp_on_ruby.configure" do |app|
28
+ # Set up default configuration
29
+ McpOnRuby.configure do |config|
30
+ config.log_level = Rails.logger.level
31
+ end
32
+
33
+ # Add MCP configuration to Rails application
34
+ app.config.mcp = ActiveSupport::OrderedOptions.new
35
+ app.config.mcp.enabled = false
36
+ app.config.mcp.path = '/mcp'
37
+ app.config.mcp.authentication_required = false
38
+ app.config.mcp.authentication_token = nil
39
+ app.config.mcp.rate_limit_per_minute = 60
40
+ app.config.mcp.auto_register_tools = true
41
+ app.config.mcp.auto_register_resources = true
42
+ end
43
+
44
+ # Setup MCP middleware
45
+ initializer "mcp_on_ruby.setup_middleware", after: :load_config_initializers do |app|
46
+ next unless app.config.mcp.enabled
47
+
48
+ # Eager load MCP classes in development
49
+ if Rails.env.development?
50
+ tools_path = app.root.join('app/tools')
51
+ resources_path = app.root.join('app/resources')
52
+
53
+ if tools_path.exist?
54
+ Dir[tools_path.join('**/*.rb')].each { |file| require_dependency file }
55
+ end
56
+
57
+ if resources_path.exist?
58
+ Dir[resources_path.join('**/*.rb')].each { |file| require_dependency file }
59
+ end
60
+ end
61
+
62
+ # Create MCP server instance
63
+ server = McpOnRuby.server do |s|
64
+ # Auto-register tools if enabled
65
+ if app.config.mcp.auto_register_tools && defined?(ApplicationTool)
66
+ ApplicationTool.descendants.each do |tool_class|
67
+ instance = tool_class.new
68
+ s.register_tool(instance)
69
+ end
70
+ end
71
+
72
+ # Auto-register resources if enabled
73
+ if app.config.mcp.auto_register_resources && defined?(ApplicationResource)
74
+ ApplicationResource.descendants.each do |resource_class|
75
+ instance = resource_class.new
76
+ s.register_resource(instance)
77
+ end
78
+ end
79
+ end
80
+
81
+ # Mount the server middleware (before the stack is frozen)
82
+ app.middleware.use(
83
+ McpOnRuby::Transport::RackMiddleware,
84
+ server: server,
85
+ path: app.config.mcp.path,
86
+ authentication_required: app.config.mcp.authentication_required,
87
+ authentication_token: app.config.mcp.authentication_token,
88
+ rate_limit_per_minute: app.config.mcp.rate_limit_per_minute
89
+ )
90
+
91
+ # Store server reference for manual access
92
+ app.config.mcp_server = server
93
+ end
94
+
95
+ # Add rake tasks (uncomment when tasks file is created)
96
+ # rake_tasks do
97
+ # load File.expand_path('tasks/mcp.rake', __dir__)
98
+ # end
99
+
100
+ # Add generators
101
+ generators do
102
+ require_relative 'generators/install_generator'
103
+ require_relative 'generators/tool_generator'
104
+ require_relative 'generators/resource_generator'
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ # Base class for MCP resources - data sources that AI can read
5
+ class Resource
6
+ attr_reader :uri, :name, :description, :mime_type, :metadata, :tags
7
+
8
+ # Create a new resource
9
+ # @param uri [String] The resource URI (supports templates with {param})
10
+ # @param name [String] Optional human-readable name
11
+ # @param description [String] Resource description
12
+ # @param mime_type [String] MIME type of the resource content
13
+ # @param metadata [Hash] Additional metadata
14
+ # @param tags [Array<String>] Tags for categorization
15
+ def initialize(uri:, name: nil, description: '', mime_type: 'application/json', metadata: {}, tags: [])
16
+ @uri = uri.to_s
17
+ @name = name
18
+ @description = description
19
+ @mime_type = mime_type
20
+ @metadata = metadata
21
+ @tags = Array(tags)
22
+ end
23
+
24
+ # Read the resource content with given parameters
25
+ # @param params [Hash] URI template parameters
26
+ # @param context [Hash] Request context (headers, user info, etc.)
27
+ # @return [Hash] The resource content wrapped in MCP format
28
+ def read(params = {}, context = {})
29
+ # Validate parameters if this is a template
30
+ validate_template_params!(params) if template?
31
+
32
+ # Get the content
33
+ content = fetch_content(params, context)
34
+
35
+ # Wrap in MCP resource format
36
+ {
37
+ contents: [
38
+ {
39
+ uri: resolve_uri(params),
40
+ mimeType: mime_type,
41
+ text: serialize_content(content)
42
+ }
43
+ ]
44
+ }
45
+ rescue => error
46
+ McpOnRuby.logger.error("Resource '#{uri}' read failed: #{error.message}")
47
+ McpOnRuby.logger.error(error.backtrace.join("\n"))
48
+
49
+ {
50
+ error: {
51
+ code: -32603,
52
+ message: "Resource read failed: #{error.message}",
53
+ data: { uri: uri, error_type: error.class.name }
54
+ }
55
+ }
56
+ end
57
+
58
+ # Get the resource schema for MCP protocol
59
+ # @return [Hash] The resource schema
60
+ def to_schema
61
+ schema = {
62
+ uri: uri,
63
+ mimeType: mime_type
64
+ }
65
+
66
+ schema[:name] = name if name
67
+ schema[:description] = description unless description.empty?
68
+ schema[:metadata] = metadata unless metadata.empty?
69
+ schema[:tags] = tags unless tags.empty?
70
+
71
+ schema
72
+ end
73
+
74
+ # Check if this resource is a template (contains {param} placeholders)
75
+ # @return [Boolean] True if resource URI contains template parameters
76
+ def template?
77
+ uri.include?('{') && uri.include?('}')
78
+ end
79
+
80
+ # Extract parameter names from template URI
81
+ # @return [Array<String>] Parameter names
82
+ def template_params
83
+ return [] unless template?
84
+
85
+ uri.scan(/\{([^}]+)\}/).flatten
86
+ end
87
+
88
+ # Check if resource is authorized for the given context
89
+ # @param context [Hash] Request context
90
+ # @return [Boolean] True if authorized
91
+ def authorized?(context = {})
92
+ return true unless respond_to?(:authorize, true)
93
+
94
+ authorize(context)
95
+ rescue => error
96
+ McpOnRuby.logger.warn("Authorization check failed for resource '#{uri}': #{error.message}")
97
+ false
98
+ end
99
+
100
+ protected
101
+
102
+ # Override this method to implement resource content fetching
103
+ # @param params [Hash] URI template parameters
104
+ # @param context [Hash] Request context
105
+ # @return [Object] Resource content (will be serialized)
106
+ def fetch_content(params, context)
107
+ raise NotImplementedError, "Resource '#{uri}' must implement #fetch_content method"
108
+ end
109
+
110
+ # Override this method to implement authorization logic
111
+ # @param context [Hash] Request context
112
+ # @return [Boolean] True if authorized
113
+ def authorize(context)
114
+ true
115
+ end
116
+
117
+ private
118
+
119
+ # Validate template parameters
120
+ # @param params [Hash] Parameters to validate
121
+ # @raise [McpOnRuby::ValidationError] If required parameters are missing
122
+ def validate_template_params!(params)
123
+ required_params = template_params
124
+ missing_params = required_params - params.keys.map(&:to_s)
125
+
126
+ unless missing_params.empty?
127
+ raise McpOnRuby::ValidationError,
128
+ "Resource '#{uri}' missing required parameters: #{missing_params.join(', ')}"
129
+ end
130
+ end
131
+
132
+ # Resolve URI template with parameters
133
+ # @param params [Hash] Parameters to substitute
134
+ # @return [String] Resolved URI
135
+ def resolve_uri(params)
136
+ return uri unless template?
137
+
138
+ resolved = uri.dup
139
+ params.each do |key, value|
140
+ resolved.gsub!("{#{key}}", value.to_s)
141
+ end
142
+ resolved
143
+ end
144
+
145
+ # Serialize content based on MIME type
146
+ # @param content [Object] Content to serialize
147
+ # @return [String] Serialized content
148
+ def serialize_content(content)
149
+ case mime_type
150
+ when 'application/json'
151
+ content.is_a?(String) ? content : JSON.pretty_generate(content)
152
+ when 'text/plain', 'text/html', 'text/css', 'text/javascript'
153
+ content.to_s
154
+ else
155
+ # For other types, assume it's already in the correct format
156
+ content.to_s
157
+ end
158
+ end
159
+ end
160
+
161
+ end
@@ -0,0 +1,378 @@
1
+ # frozen_string_literal: true
2
+
3
+ module McpOnRuby
4
+ # Main MCP server class that handles JSON-RPC protocol and manages tools/resources
5
+ class Server
6
+ attr_reader :tools, :resources, :configuration, :logger
7
+
8
+ # Initialize a new MCP server
9
+ # @param options [Hash] Server configuration options
10
+ # @yield [Server] Server instance for configuration
11
+ def initialize(options = {}, &block)
12
+ @configuration = McpOnRuby.configuration || Configuration.new
13
+ @configuration.tap do |config|
14
+ options.each { |key, value| config.send("#{key}=", value) if config.respond_to?("#{key}=") }
15
+ end
16
+
17
+ @logger = McpOnRuby.logger
18
+ @tools = {}
19
+ @resources = {}
20
+ @rate_limiter = RateLimiter.new(@configuration.rate_limit_per_minute)
21
+
22
+ # Configure the server using the block
23
+ instance_eval(&block) if block_given?
24
+ end
25
+
26
+ # Register a tool
27
+ # @param tool [Tool] The tool to register
28
+ # @param name [String] Optional name override
29
+ # @return [Tool] The registered tool
30
+ def register_tool(tool, name = nil)
31
+ tool_name = name || tool.name
32
+ @tools[tool_name] = tool
33
+ @logger.debug("Registered tool: #{tool_name}")
34
+ tool
35
+ end
36
+
37
+ # Register a resource
38
+ # @param resource [Resource] The resource to register
39
+ # @param uri [String] Optional URI override
40
+ # @return [Resource] The registered resource
41
+ def register_resource(resource, uri = nil)
42
+ resource_uri = uri || resource.uri
43
+ @resources[resource_uri] = resource
44
+ @logger.debug("Registered resource: #{resource_uri}")
45
+ resource
46
+ end
47
+
48
+ # DSL method to define a tool
49
+ # @param name [String] Tool name
50
+ # @param description [String] Tool description
51
+ # @param input_schema [Hash] JSON Schema for validation
52
+ # @param options [Hash] Additional options (metadata, tags)
53
+ # @param block [Proc] Tool implementation
54
+ # @return [Tool] The created and registered tool
55
+ def tool(name, description = '', input_schema = {}, **options, &block)
56
+ tool_instance = McpOnRuby.tool(name, description, input_schema, **options, &block)
57
+ register_tool(tool_instance)
58
+ end
59
+
60
+ # DSL method to define a resource
61
+ # @param uri [String] Resource URI
62
+ # @param options [Hash] Resource options (name, description, etc.)
63
+ # @param block [Proc] Resource implementation
64
+ # @return [Resource] The created and registered resource
65
+ def resource(uri, **options, &block)
66
+ resource_instance = McpOnRuby.resource(uri, **options, &block)
67
+ register_resource(resource_instance)
68
+ end
69
+
70
+ # Handle a JSON-RPC request
71
+ # @param request_body [String] JSON request body
72
+ # @param context [Hash] Request context (headers, IP, etc.)
73
+ # @return [String, nil] JSON response or nil for notifications
74
+ def handle_request(request_body, context = {})
75
+ # Parse JSON request
76
+ request = JSON.parse(request_body)
77
+
78
+ # Rate limiting check
79
+ unless @rate_limiter.allowed?(context[:remote_ip])
80
+ return error_response(nil, -32603, "Rate limit exceeded")
81
+ end
82
+
83
+ # Handle the request
84
+ response = handle_json_rpc(request, context)
85
+
86
+ # Return JSON response for requests with ID, nil for notifications
87
+ response ? JSON.generate(response) : nil
88
+
89
+ rescue JSON::ParserError => e
90
+ @logger.warn("Invalid JSON request: #{e.message}")
91
+ JSON.generate(error_response(nil, -32700, "Parse error"))
92
+ rescue => e
93
+ @logger.error("Request handling failed: #{e.message}")
94
+ @logger.error(e.backtrace.join("\n"))
95
+ JSON.generate(error_response(nil, -32603, "Internal error"))
96
+ end
97
+
98
+ # Get server capabilities for MCP initialization
99
+ # @return [Hash] Server capabilities
100
+ def capabilities
101
+ {
102
+ tools: tools.any? ? {} : nil,
103
+ resources: resources.any? ? { subscribe: @configuration.enable_sse } : nil
104
+ }.compact
105
+ end
106
+
107
+ # Get server information
108
+ # @return [Hash] Server info
109
+ def server_info
110
+ {
111
+ name: "mcp_on_ruby",
112
+ version: McpOnRuby::VERSION
113
+ }
114
+ end
115
+
116
+ private
117
+
118
+ # Handle JSON-RPC protocol
119
+ # @param request [Hash] Parsed JSON-RPC request
120
+ # @param context [Hash] Request context
121
+ # @return [Hash, nil] Response hash or nil for notifications
122
+ def handle_json_rpc(request, context)
123
+ method = request['method']
124
+ params = request['params'] || {}
125
+ id = request['id']
126
+
127
+ @logger.debug("Handling method: #{method}")
128
+
129
+ case method
130
+ when 'initialize'
131
+ success_response(id, handle_initialize(params))
132
+ when 'tools/list'
133
+ success_response(id, handle_tools_list(context))
134
+ when 'tools/call'
135
+ success_response(id, handle_tool_call(params, context))
136
+ when 'resources/list'
137
+ success_response(id, handle_resources_list(context))
138
+ when 'resources/read'
139
+ success_response(id, handle_resource_read(params, context))
140
+ when 'ping'
141
+ success_response(id, { pong: true })
142
+ else
143
+ id ? error_response(id, -32601, "Method not found: #{method}") : nil
144
+ end
145
+ end
146
+
147
+ # Handle initialization request
148
+ # @param params [Hash] Initialization parameters
149
+ # @return [Hash] Initialization response
150
+ def handle_initialize(params)
151
+ client_info = params['clientInfo'] || {}
152
+ protocol_version = params['protocolVersion']
153
+
154
+ @logger.info("Client connected: #{client_info['name']} #{client_info['version']}")
155
+
156
+ {
157
+ serverInfo: server_info,
158
+ protocolVersion: McpOnRuby::PROTOCOL_VERSION,
159
+ capabilities: capabilities
160
+ }
161
+ end
162
+
163
+ # Handle tools list request
164
+ # @param context [Hash] Request context
165
+ # @return [Hash] Tools list response
166
+ def handle_tools_list(context)
167
+ authorized_tools = @tools.select { |_, tool| tool.authorized?(context) }
168
+
169
+ {
170
+ tools: authorized_tools.values.map(&:to_schema)
171
+ }
172
+ end
173
+
174
+ # Handle tool call request
175
+ # @param params [Hash] Tool call parameters
176
+ # @param context [Hash] Request context
177
+ # @return [Hash] Tool call response
178
+ def handle_tool_call(params, context)
179
+ tool_name = params['name']
180
+ arguments = params['arguments'] || {}
181
+
182
+ tool = @tools[tool_name]
183
+ unless tool
184
+ raise McpOnRuby::NotFoundError, "Tool not found: #{tool_name}"
185
+ end
186
+
187
+ unless tool.authorized?(context)
188
+ raise McpOnRuby::AuthorizationError, "Not authorized to call tool: #{tool_name}"
189
+ end
190
+
191
+ result = tool.call(arguments, context)
192
+
193
+ # Handle error results from tool execution
194
+ if result.key?(:error)
195
+ raise McpOnRuby::ToolExecutionError, result[:error][:message]
196
+ end
197
+
198
+ {
199
+ content: [
200
+ {
201
+ type: "text",
202
+ text: serialize_tool_result(result)
203
+ }
204
+ ]
205
+ }
206
+ end
207
+
208
+ # Handle resources list request
209
+ # @param context [Hash] Request context
210
+ # @return [Hash] Resources list response
211
+ def handle_resources_list(context)
212
+ authorized_resources = @resources.select { |_, resource| resource.authorized?(context) }
213
+
214
+ {
215
+ resources: authorized_resources.values.map(&:to_schema)
216
+ }
217
+ end
218
+
219
+ # Handle resource read request
220
+ # @param params [Hash] Resource read parameters
221
+ # @param context [Hash] Request context
222
+ # @return [Hash] Resource read response
223
+ def handle_resource_read(params, context)
224
+ uri = params['uri']
225
+
226
+ # Find exact match or template match
227
+ resource = find_resource(uri)
228
+ unless resource
229
+ raise McpOnRuby::NotFoundError, "Resource not found: #{uri}"
230
+ end
231
+
232
+ unless resource.authorized?(context)
233
+ raise McpOnRuby::AuthorizationError, "Not authorized to read resource: #{uri}"
234
+ end
235
+
236
+ # Extract parameters from URI if it's a template
237
+ template_params = extract_template_params(resource.uri, uri)
238
+
239
+ result = resource.read(template_params, context)
240
+
241
+ # Handle error results from resource reading
242
+ if result.key?(:error)
243
+ raise McpOnRuby::ResourceReadError, result[:error][:message]
244
+ end
245
+
246
+ result
247
+ end
248
+
249
+ # Find a resource by URI (exact match or template match)
250
+ # @param uri [String] The URI to find
251
+ # @return [Resource, nil] The matching resource
252
+ def find_resource(uri)
253
+ # Try exact match first
254
+ return @resources[uri] if @resources.key?(uri)
255
+
256
+ # Try template matching
257
+ @resources.each do |template_uri, resource|
258
+ next unless resource.template?
259
+
260
+ if uri_matches_template?(template_uri, uri)
261
+ return resource
262
+ end
263
+ end
264
+
265
+ nil
266
+ end
267
+
268
+ # Check if a URI matches a template
269
+ # @param template [String] Template URI with {param} placeholders
270
+ # @param uri [String] Actual URI to match
271
+ # @return [Boolean] True if URI matches template
272
+ def uri_matches_template?(template, uri)
273
+ # Convert template to regex
274
+ regex_pattern = template.gsub(/\{[^}]+\}/, '([^/]+)')
275
+ regex = /^#{regex_pattern}$/
276
+
277
+ uri =~ regex
278
+ end
279
+
280
+ # Extract parameters from URI using template
281
+ # @param template [String] Template URI
282
+ # @param uri [String] Actual URI
283
+ # @return [Hash] Extracted parameters
284
+ def extract_template_params(template, uri)
285
+ return {} unless template.include?('{')
286
+
287
+ # Get parameter names
288
+ param_names = template.scan(/\{([^}]+)\}/).flatten
289
+
290
+ # Convert template to regex with capture groups
291
+ regex_pattern = template.gsub(/\{[^}]+\}/, '([^/]+)')
292
+ regex = /^#{regex_pattern}$/
293
+
294
+ # Extract values
295
+ matches = uri.match(regex)
296
+ return {} unless matches
297
+
298
+ # Build parameter hash
299
+ param_names.zip(matches.captures).to_h
300
+ end
301
+
302
+ # Serialize tool result for response
303
+ # @param result [Object] Tool execution result
304
+ # @return [String] Serialized result
305
+ def serialize_tool_result(result)
306
+ case result
307
+ when String
308
+ result
309
+ when Hash, Array
310
+ JSON.pretty_generate(result)
311
+ else
312
+ result.to_s
313
+ end
314
+ end
315
+
316
+ # Create success response
317
+ # @param id [String, Integer] Request ID
318
+ # @param result [Object] Response result
319
+ # @return [Hash] Success response
320
+ def success_response(id, result)
321
+ {
322
+ jsonrpc: "2.0",
323
+ id: id,
324
+ result: result
325
+ }
326
+ end
327
+
328
+ # Create error response
329
+ # @param id [String, Integer, nil] Request ID
330
+ # @param code [Integer] Error code
331
+ # @param message [String] Error message
332
+ # @param data [Object] Additional error data
333
+ # @return [Hash] Error response
334
+ def error_response(id, code, message, data = nil)
335
+ response = {
336
+ jsonrpc: "2.0",
337
+ id: id,
338
+ error: {
339
+ code: code,
340
+ message: message
341
+ }
342
+ }
343
+ response[:error][:data] = data if data
344
+ response
345
+ end
346
+
347
+ # Simple rate limiter
348
+ class RateLimiter
349
+ def initialize(requests_per_minute)
350
+ @requests_per_minute = requests_per_minute
351
+ @requests = {}
352
+ @mutex = Mutex.new
353
+ end
354
+
355
+ def allowed?(ip)
356
+ return true if @requests_per_minute <= 0
357
+
358
+ @mutex.synchronize do
359
+ now = Time.now.to_i
360
+ minute = now / 60
361
+
362
+ @requests[ip] ||= {}
363
+ @requests[ip][minute] ||= 0
364
+
365
+ # Clean old entries
366
+ @requests[ip].delete_if { |m, _| m < minute }
367
+
368
+ if @requests[ip][minute] >= @requests_per_minute
369
+ false
370
+ else
371
+ @requests[ip][minute] += 1
372
+ true
373
+ end
374
+ end
375
+ end
376
+ end
377
+ end
378
+ end