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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -28
- data/CODE_OF_CONDUCT.md +30 -58
- data/CONTRIBUTING.md +61 -67
- data/LICENSE.txt +2 -2
- data/README.md +159 -509
- data/bin/console +11 -0
- data/bin/setup +6 -0
- data/docs/advanced-usage.md +132 -0
- data/docs/api-reference.md +35 -0
- data/docs/testing.md +55 -0
- data/examples/claude/README.md +171 -0
- data/examples/claude/claude-bridge.js +122 -0
- data/lib/mcp_on_ruby/configuration.rb +74 -0
- data/lib/mcp_on_ruby/errors.rb +137 -0
- data/lib/mcp_on_ruby/generators/install_generator.rb +46 -0
- data/lib/mcp_on_ruby/generators/resource_generator.rb +63 -0
- data/lib/mcp_on_ruby/generators/templates/README +31 -0
- data/lib/mcp_on_ruby/generators/templates/application_resource.rb +20 -0
- data/lib/mcp_on_ruby/generators/templates/application_tool.rb +18 -0
- data/lib/mcp_on_ruby/generators/templates/initializer.rb +41 -0
- data/lib/mcp_on_ruby/generators/templates/resource.rb +50 -0
- data/lib/mcp_on_ruby/generators/templates/resource_spec.rb +67 -0
- data/lib/mcp_on_ruby/generators/templates/sample_resource.rb +57 -0
- data/lib/mcp_on_ruby/generators/templates/sample_tool.rb +59 -0
- data/lib/mcp_on_ruby/generators/templates/tool.rb +38 -0
- data/lib/mcp_on_ruby/generators/templates/tool_spec.rb +55 -0
- data/lib/mcp_on_ruby/generators/tool_generator.rb +51 -0
- data/lib/mcp_on_ruby/railtie.rb +108 -0
- data/lib/mcp_on_ruby/resource.rb +161 -0
- data/lib/mcp_on_ruby/server.rb +378 -0
- data/lib/mcp_on_ruby/tool.rb +134 -0
- data/lib/mcp_on_ruby/transport.rb +330 -0
- data/lib/mcp_on_ruby/version.rb +6 -0
- data/lib/mcp_on_ruby.rb +142 -0
- metadata +62 -173
- data/lib/ruby_mcp/client.rb +0 -43
- data/lib/ruby_mcp/configuration.rb +0 -90
- data/lib/ruby_mcp/errors.rb +0 -17
- data/lib/ruby_mcp/models/context.rb +0 -52
- data/lib/ruby_mcp/models/engine.rb +0 -31
- data/lib/ruby_mcp/models/message.rb +0 -60
- data/lib/ruby_mcp/providers/anthropic.rb +0 -269
- data/lib/ruby_mcp/providers/base.rb +0 -57
- data/lib/ruby_mcp/providers/openai.rb +0 -265
- data/lib/ruby_mcp/schemas.rb +0 -56
- data/lib/ruby_mcp/server/app.rb +0 -84
- data/lib/ruby_mcp/server/base_controller.rb +0 -49
- data/lib/ruby_mcp/server/content_controller.rb +0 -68
- data/lib/ruby_mcp/server/contexts_controller.rb +0 -67
- data/lib/ruby_mcp/server/controller.rb +0 -29
- data/lib/ruby_mcp/server/engines_controller.rb +0 -34
- data/lib/ruby_mcp/server/generate_controller.rb +0 -140
- data/lib/ruby_mcp/server/messages_controller.rb +0 -30
- data/lib/ruby_mcp/server/router.rb +0 -84
- data/lib/ruby_mcp/storage/active_record.rb +0 -414
- data/lib/ruby_mcp/storage/base.rb +0 -43
- data/lib/ruby_mcp/storage/error.rb +0 -8
- data/lib/ruby_mcp/storage/memory.rb +0 -69
- data/lib/ruby_mcp/storage/redis.rb +0 -197
- data/lib/ruby_mcp/storage_factory.rb +0 -43
- data/lib/ruby_mcp/validator.rb +0 -45
- data/lib/ruby_mcp/version.rb +0 -6
- 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
|