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,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
|
data/lib/mcp_on_ruby.rb
ADDED
@@ -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
|