fast-mcp 0.1.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 +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE +21 -0
- data/README.md +321 -0
- data/lib/fast_mcp.rb +69 -0
- data/lib/mcp/logger.rb +33 -0
- data/lib/mcp/resource.rb +158 -0
- data/lib/mcp/server.rb +491 -0
- data/lib/mcp/tool.rb +808 -0
- data/lib/mcp/transports/authenticated_rack_transport.rb +72 -0
- data/lib/mcp/transports/base_transport.rb +40 -0
- data/lib/mcp/transports/rack_transport.rb +468 -0
- data/lib/mcp/transports/stdio_transport.rb +62 -0
- data/lib/mcp/version.rb +6 -0
- metadata +102 -0
data/lib/mcp/server.rb
ADDED
@@ -0,0 +1,491 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'logger'
|
5
|
+
require 'securerandom'
|
6
|
+
require 'base64'
|
7
|
+
require_relative 'transports/stdio_transport'
|
8
|
+
require_relative 'transports/rack_transport'
|
9
|
+
require_relative 'transports/authenticated_rack_transport'
|
10
|
+
require_relative 'logger'
|
11
|
+
|
12
|
+
module MCP
|
13
|
+
class Server
|
14
|
+
attr_reader :name, :version, :tools, :resources, :logger, :transport, :capabilities
|
15
|
+
|
16
|
+
DEFAULT_CAPABILITIES = {
|
17
|
+
resources: {
|
18
|
+
subscribe: true,
|
19
|
+
listChanged: true
|
20
|
+
},
|
21
|
+
tools: {
|
22
|
+
listChanged: true
|
23
|
+
}
|
24
|
+
}.freeze
|
25
|
+
|
26
|
+
def initialize(name:, version:, logger: MCP::Logger.new, capabilities: {})
|
27
|
+
@name = name
|
28
|
+
@version = version
|
29
|
+
@tools = {}
|
30
|
+
@resources = {}
|
31
|
+
@resource_subscriptions = {}
|
32
|
+
@logger = logger
|
33
|
+
@logger.level = Logger::INFO
|
34
|
+
@request_id = 0
|
35
|
+
@transport = nil
|
36
|
+
@capabilities = DEFAULT_CAPABILITIES.dup
|
37
|
+
|
38
|
+
# Merge with provided capabilities
|
39
|
+
@capabilities.merge!(capabilities) if capabilities.is_a?(Hash)
|
40
|
+
end
|
41
|
+
|
42
|
+
def register_tools(*tools)
|
43
|
+
tools.each do |tool|
|
44
|
+
register_tool(tool)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Register a tool with the server
|
49
|
+
def register_tool(tool)
|
50
|
+
@tools[tool.tool_name] = tool
|
51
|
+
@logger.info("Registered tool: #{tool.tool_name}")
|
52
|
+
end
|
53
|
+
|
54
|
+
# Register multiple resources at once
|
55
|
+
# @param resources [Array<Resource>] Resources to register
|
56
|
+
def register_resources(*resources)
|
57
|
+
resources.each do |resource|
|
58
|
+
register_resource(resource)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
# Register a resource with the server
|
63
|
+
def register_resource(resource)
|
64
|
+
@resources[resource.uri] = resource
|
65
|
+
@logger.info("Registered resource: #{resource.name} (#{resource.uri})")
|
66
|
+
|
67
|
+
# Notify subscribers about the list change
|
68
|
+
notify_resource_list_changed if @transport
|
69
|
+
|
70
|
+
resource
|
71
|
+
end
|
72
|
+
|
73
|
+
# Remove a resource from the server
|
74
|
+
def remove_resource(uri)
|
75
|
+
if @resources.key?(uri)
|
76
|
+
resource = @resources.delete(uri)
|
77
|
+
@logger.info("Removed resource: #{resource.name} (#{uri})")
|
78
|
+
|
79
|
+
# Notify subscribers about the list change
|
80
|
+
notify_resource_list_changed if @transport
|
81
|
+
|
82
|
+
true
|
83
|
+
else
|
84
|
+
false
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Start the server using stdio transport
|
89
|
+
def start
|
90
|
+
@logger.transport = :stdio
|
91
|
+
@logger.info("Starting MCP server: #{@name} v#{@version}")
|
92
|
+
@logger.info("Available tools: #{@tools.keys.join(', ')}")
|
93
|
+
@logger.info("Available resources: #{@resources.keys.join(', ')}")
|
94
|
+
|
95
|
+
# Use STDIO transport by default
|
96
|
+
@transport = MCP::Transports::StdioTransport.new(self, logger: @logger)
|
97
|
+
@transport.start
|
98
|
+
end
|
99
|
+
|
100
|
+
# Start the server as a Rack middleware
|
101
|
+
def start_rack(app, options = {})
|
102
|
+
@logger.info("Starting MCP server as Rack middleware: #{@name} v#{@version}")
|
103
|
+
@logger.info("Available tools: #{@tools.keys.join(', ')}")
|
104
|
+
@logger.info("Available resources: #{@resources.keys.join(', ')}")
|
105
|
+
|
106
|
+
# Use Rack transport
|
107
|
+
@transport = MCP::Transports::RackTransport.new(self, app, options.merge(logger: @logger))
|
108
|
+
@transport.start
|
109
|
+
|
110
|
+
# Return the transport as middleware
|
111
|
+
@transport
|
112
|
+
end
|
113
|
+
|
114
|
+
def start_authenticated_rack(app, options = {})
|
115
|
+
@logger.info("Starting MCP server as Authenticated Rack middleware: #{@name} v#{@version}")
|
116
|
+
@logger.info("Available tools: #{@tools.keys.join(', ')}")
|
117
|
+
@logger.info("Available resources: #{@resources.keys.join(', ')}")
|
118
|
+
|
119
|
+
# Use Rack transport
|
120
|
+
@transport = MCP::Transports::AuthenticatedRackTransport.new(self, app, options.merge(logger: @logger))
|
121
|
+
@transport.start
|
122
|
+
|
123
|
+
# Return the transport as middleware
|
124
|
+
@transport
|
125
|
+
end
|
126
|
+
|
127
|
+
# Handle incoming JSON-RPC request
|
128
|
+
def handle_request(json_str) # rubocop:disable Metrics/MethodLength
|
129
|
+
begin
|
130
|
+
request = JSON.parse(json_str)
|
131
|
+
rescue JSON::ParserError, TypeError
|
132
|
+
return send_error(-32_600, 'Invalid Request', nil)
|
133
|
+
end
|
134
|
+
|
135
|
+
@logger.debug("Received request: #{request.inspect}")
|
136
|
+
|
137
|
+
# Check if it's a valid JSON-RPC 2.0 request
|
138
|
+
unless request['jsonrpc'] == '2.0' && request['method']
|
139
|
+
return send_error(-32_600, 'Invalid Request', request['id'])
|
140
|
+
end
|
141
|
+
|
142
|
+
method = request['method']
|
143
|
+
params = request['params'] || {}
|
144
|
+
id = request['id']
|
145
|
+
|
146
|
+
case method
|
147
|
+
when 'ping'
|
148
|
+
send_result({}, id)
|
149
|
+
when 'initialize'
|
150
|
+
handle_initialize(params, id)
|
151
|
+
when 'notifications/initialized'
|
152
|
+
handle_initialized_notification
|
153
|
+
when 'tools/list'
|
154
|
+
handle_tools_list(id)
|
155
|
+
when 'tools/call'
|
156
|
+
handle_tools_call(params, id)
|
157
|
+
when 'resources/list'
|
158
|
+
handle_resources_list(id)
|
159
|
+
when 'resources/read'
|
160
|
+
handle_resources_read(params, id)
|
161
|
+
when 'resources/subscribe'
|
162
|
+
handle_resources_subscribe(params, id)
|
163
|
+
when 'resources/unsubscribe'
|
164
|
+
handle_resources_unsubscribe(params, id)
|
165
|
+
else
|
166
|
+
send_error(-32_601, "Method not found: #{method}", id)
|
167
|
+
end
|
168
|
+
rescue StandardError => e
|
169
|
+
@logger.error("Error handling request: #{e.message}, #{e.backtrace.join("\n")}")
|
170
|
+
send_error(-32_600, "Internal error: #{e.message}", id)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Handle a JSON-RPC request and return the response as a JSON string
|
174
|
+
def handle_json_request(request)
|
175
|
+
# Process the request
|
176
|
+
if request.is_a?(String)
|
177
|
+
handle_request(request)
|
178
|
+
else
|
179
|
+
handle_request(JSON.generate(request))
|
180
|
+
end
|
181
|
+
end
|
182
|
+
|
183
|
+
# Register a callback for resource updates
|
184
|
+
def on_resource_update(&block)
|
185
|
+
@resource_update_callbacks ||= []
|
186
|
+
callback_id = SecureRandom.uuid
|
187
|
+
@resource_update_callbacks << { id: callback_id, callback: block }
|
188
|
+
callback_id
|
189
|
+
end
|
190
|
+
|
191
|
+
# Remove a resource update callback
|
192
|
+
def remove_resource_update_callback(callback_id)
|
193
|
+
@resource_update_callbacks ||= []
|
194
|
+
@resource_update_callbacks.reject! { |cb| cb[:id] == callback_id }
|
195
|
+
end
|
196
|
+
|
197
|
+
# Update a resource and notify subscribers
|
198
|
+
def update_resource(uri, content)
|
199
|
+
return false unless @resources.key?(uri)
|
200
|
+
|
201
|
+
resource = @resources[uri]
|
202
|
+
resource.instance.content = content
|
203
|
+
|
204
|
+
# Notify subscribers
|
205
|
+
notify_resource_updated(uri) if @transport && @resource_subscriptions.key?(uri)
|
206
|
+
|
207
|
+
# Notify resource update callbacks
|
208
|
+
if @resource_update_callbacks && !@resource_update_callbacks.empty?
|
209
|
+
@resource_update_callbacks.each do |cb|
|
210
|
+
cb[:callback].call(
|
211
|
+
{
|
212
|
+
uri: uri,
|
213
|
+
name: resource.name,
|
214
|
+
mime_type: resource.mime_type,
|
215
|
+
content: content
|
216
|
+
}
|
217
|
+
)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
true
|
222
|
+
end
|
223
|
+
|
224
|
+
# Read a resource directly
|
225
|
+
def read_resource(uri)
|
226
|
+
resource = @resources[uri]
|
227
|
+
raise "Resource not found: #{uri}" unless resource
|
228
|
+
|
229
|
+
resource
|
230
|
+
end
|
231
|
+
|
232
|
+
private
|
233
|
+
|
234
|
+
PROTOCOL_VERSION = '2024-11-05'
|
235
|
+
|
236
|
+
def handle_initialize(params, id)
|
237
|
+
params['protocolVersion']
|
238
|
+
client_capabilities = params['capabilities'] || {}
|
239
|
+
client_info = params['clientInfo'] || {}
|
240
|
+
|
241
|
+
# Log client information
|
242
|
+
@logger.info("Client connected: #{client_info['name']} v#{client_info['version']}")
|
243
|
+
# @logger.debug("Client capabilities: #{client_capabilities.inspect}")
|
244
|
+
|
245
|
+
# Prepare server response
|
246
|
+
response = {
|
247
|
+
protocolVersion: PROTOCOL_VERSION, # For now, only version 2024-11-05 is supported.
|
248
|
+
capabilities: @capabilities,
|
249
|
+
serverInfo: {
|
250
|
+
name: @name,
|
251
|
+
version: @version
|
252
|
+
}
|
253
|
+
}
|
254
|
+
|
255
|
+
@logger.info("Server response: #{response.inspect}")
|
256
|
+
|
257
|
+
# Store client capabilities for later use
|
258
|
+
@client_capabilities = client_capabilities
|
259
|
+
|
260
|
+
send_result(response, id)
|
261
|
+
end
|
262
|
+
|
263
|
+
# Handle a resource read
|
264
|
+
def handle_resources_read(params, id)
|
265
|
+
uri = params['uri']
|
266
|
+
|
267
|
+
return send_error(-32_602, 'Invalid params: missing resource URI', id) unless uri
|
268
|
+
|
269
|
+
resource = @resources[uri]
|
270
|
+
return send_error(-32_602, "Resource not found: #{uri}", id) unless resource
|
271
|
+
|
272
|
+
base_content = { uri: resource.uri }
|
273
|
+
base_content[:mimeType] = resource.mime_type if resource.mime_type
|
274
|
+
resource_instance = resource.instance
|
275
|
+
# Format the response according to the MCP specification
|
276
|
+
result = if resource_instance.binary?
|
277
|
+
{
|
278
|
+
contents: [base_content.merge(blob: Base64.strict_encode64(resource_instance.content))]
|
279
|
+
}
|
280
|
+
else
|
281
|
+
{
|
282
|
+
contents: [base_content.merge(text: resource_instance.content)]
|
283
|
+
}
|
284
|
+
end
|
285
|
+
|
286
|
+
send_result(result, id)
|
287
|
+
end
|
288
|
+
|
289
|
+
def handle_initialized_notification
|
290
|
+
# The client is now ready for normal operation
|
291
|
+
# No response needed for notifications
|
292
|
+
@client_initialized = true
|
293
|
+
@logger.set_client_initialized
|
294
|
+
@logger.info('Client initialized, beginning normal operation')
|
295
|
+
end
|
296
|
+
|
297
|
+
# Handle tools/list request
|
298
|
+
def handle_tools_list(id)
|
299
|
+
tools_list = @tools.values.map do |tool|
|
300
|
+
{
|
301
|
+
name: tool.tool_name,
|
302
|
+
description: tool.description || '',
|
303
|
+
inputSchema: tool.input_schema_to_json || { type: 'object', properties: {}, required: [] }
|
304
|
+
}
|
305
|
+
end
|
306
|
+
|
307
|
+
send_result({ tools: tools_list }, id)
|
308
|
+
end
|
309
|
+
|
310
|
+
# Handle tools/call request
|
311
|
+
def handle_tools_call(params, id)
|
312
|
+
tool_name = params['name']
|
313
|
+
arguments = params['arguments'] || {}
|
314
|
+
|
315
|
+
return send_error(-32_602, 'Invalid params: missing tool name', id) unless tool_name
|
316
|
+
|
317
|
+
tool = @tools[tool_name]
|
318
|
+
return send_error(-32_602, "Tool not found: #{tool_name}", id) unless tool
|
319
|
+
|
320
|
+
begin
|
321
|
+
# Convert string keys to symbols for Ruby
|
322
|
+
symbolized_args = symbolize_keys(arguments)
|
323
|
+
result = tool.new.call_with_schema_validation!(**symbolized_args)
|
324
|
+
|
325
|
+
# Format and send the result
|
326
|
+
send_formatted_result(result, id)
|
327
|
+
rescue MCP::Tool::InvalidArgumentsError => e
|
328
|
+
@logger.error("Invalid arguments for tool #{tool_name}: #{e.message}")
|
329
|
+
send_error_result(e.message, id)
|
330
|
+
rescue StandardError => e
|
331
|
+
@logger.error("Error calling tool #{tool_name}: #{e.message}")
|
332
|
+
send_error_result("#{e.message}, #{e.backtrace.join("\n")}", id)
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
# Format and send successful result
|
337
|
+
def send_formatted_result(result, id)
|
338
|
+
# Check if the result is already in the expected format
|
339
|
+
if result.is_a?(Hash) && result.key?(:content)
|
340
|
+
# Result is already in the correct format
|
341
|
+
send_result(result, id)
|
342
|
+
else
|
343
|
+
# Format the result according to the MCP specification
|
344
|
+
formatted_result = {
|
345
|
+
content: [{ type: 'text', text: result.to_s }],
|
346
|
+
isError: false
|
347
|
+
}
|
348
|
+
send_result(formatted_result, id)
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
# Format and send error result
|
353
|
+
def send_error_result(message, id)
|
354
|
+
# Format error according to the MCP specification
|
355
|
+
error_result = {
|
356
|
+
content: [{ type: 'text', text: "Error: #{message}" }],
|
357
|
+
isError: true
|
358
|
+
}
|
359
|
+
send_result(error_result, id)
|
360
|
+
end
|
361
|
+
|
362
|
+
# Handle resources/list request
|
363
|
+
def handle_resources_list(id)
|
364
|
+
resources_list = @resources.values.map(&:metadata)
|
365
|
+
|
366
|
+
send_result({ resources: resources_list }, id)
|
367
|
+
end
|
368
|
+
|
369
|
+
# Handle resources/subscribe request
|
370
|
+
def handle_resources_subscribe(params, id)
|
371
|
+
return unless @client_initialized
|
372
|
+
|
373
|
+
uri = params['uri']
|
374
|
+
|
375
|
+
unless uri
|
376
|
+
send_error(-32_602, 'Invalid params: missing resource URI', id)
|
377
|
+
return
|
378
|
+
end
|
379
|
+
|
380
|
+
resource = @resources[uri]
|
381
|
+
unless resource
|
382
|
+
send_error(-32_602, "Resource not found: #{uri}", id)
|
383
|
+
return
|
384
|
+
end
|
385
|
+
|
386
|
+
# Add to subscriptions
|
387
|
+
@resource_subscriptions[uri] ||= []
|
388
|
+
@resource_subscriptions[uri] << id
|
389
|
+
|
390
|
+
send_result({ subscribed: true }, id)
|
391
|
+
end
|
392
|
+
|
393
|
+
# Handle resources/unsubscribe request
|
394
|
+
def handle_resources_unsubscribe(params, id)
|
395
|
+
return unless @client_initialized
|
396
|
+
|
397
|
+
uri = params['uri']
|
398
|
+
|
399
|
+
unless uri
|
400
|
+
send_error(-32_602, 'Invalid params: missing resource URI', id)
|
401
|
+
return
|
402
|
+
end
|
403
|
+
|
404
|
+
# Remove from subscriptions
|
405
|
+
if @resource_subscriptions.key?(uri)
|
406
|
+
@resource_subscriptions[uri].delete(id)
|
407
|
+
@resource_subscriptions.delete(uri) if @resource_subscriptions[uri].empty?
|
408
|
+
end
|
409
|
+
|
410
|
+
send_result({ unsubscribed: true }, id)
|
411
|
+
end
|
412
|
+
|
413
|
+
# Notify subscribers about a resource update
|
414
|
+
def notify_resource_updated(uri)
|
415
|
+
return unless @client_initialized && @resource_subscriptions.key?(uri)
|
416
|
+
|
417
|
+
resource = @resources[uri]
|
418
|
+
notification = {
|
419
|
+
jsonrpc: '2.0',
|
420
|
+
method: 'notifications/resources/updated',
|
421
|
+
params: {
|
422
|
+
uri: uri,
|
423
|
+
name: resource.name,
|
424
|
+
mimeType: resource.mime_type
|
425
|
+
}
|
426
|
+
}
|
427
|
+
|
428
|
+
@transport.send_message(notification)
|
429
|
+
end
|
430
|
+
|
431
|
+
# Notify clients about resource list changes
|
432
|
+
def notify_resource_list_changed
|
433
|
+
return unless @client_initialized
|
434
|
+
|
435
|
+
notification = {
|
436
|
+
jsonrpc: '2.0',
|
437
|
+
method: 'notifications/resources/listChanged',
|
438
|
+
params: {}
|
439
|
+
}
|
440
|
+
|
441
|
+
@transport.send_message(notification)
|
442
|
+
end
|
443
|
+
|
444
|
+
# Send a JSON-RPC result response
|
445
|
+
def send_result(result, id)
|
446
|
+
response = {
|
447
|
+
jsonrpc: '2.0',
|
448
|
+
id: id,
|
449
|
+
result: result
|
450
|
+
}
|
451
|
+
|
452
|
+
@logger.info("Sending result: #{response.inspect}")
|
453
|
+
send_response(response)
|
454
|
+
end
|
455
|
+
|
456
|
+
# Send a JSON-RPC error response
|
457
|
+
def send_error(code, message, id = nil)
|
458
|
+
response = {
|
459
|
+
jsonrpc: '2.0',
|
460
|
+
error: {
|
461
|
+
code: code,
|
462
|
+
message: message
|
463
|
+
},
|
464
|
+
id: id
|
465
|
+
}
|
466
|
+
send_response(response)
|
467
|
+
end
|
468
|
+
|
469
|
+
# Send a JSON-RPC response
|
470
|
+
def send_response(response)
|
471
|
+
if @transport
|
472
|
+
@logger.info("Sending response: #{response.inspect}")
|
473
|
+
@logger.info("Transport: #{@transport.inspect}")
|
474
|
+
@transport.send_message(response)
|
475
|
+
else
|
476
|
+
@logger.warn("No transport available to send response: #{response.inspect}")
|
477
|
+
end
|
478
|
+
end
|
479
|
+
|
480
|
+
# Helper method to convert string keys to symbols
|
481
|
+
def symbolize_keys(hash)
|
482
|
+
return hash unless hash.is_a?(Hash)
|
483
|
+
|
484
|
+
hash.each_with_object({}) do |(key, value), result|
|
485
|
+
new_key = key.is_a?(String) ? key.to_sym : key
|
486
|
+
new_value = value.is_a?(Hash) ? symbolize_keys(value) : value
|
487
|
+
result[new_key] = new_value
|
488
|
+
end
|
489
|
+
end
|
490
|
+
end
|
491
|
+
end
|