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.
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