mcp-sdk.rb 0.1.2 → 0.1.4

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,629 @@
1
+ require "json"
2
+ require "sinatra/base"
3
+ require "puma"
4
+ require "securerandom"
5
+ require_relative "enhanced_sse_server"
6
+
7
+ module MCP
8
+ class Server
9
+ attr_reader :name, :version, :type, :port, :tools
10
+
11
+ def initialize(name:, version:, type: "stdio", port: nil)
12
+ @name = name
13
+ @version = version
14
+ @type = type.to_s
15
+ @port = port
16
+ @tools = {}
17
+ @running = false
18
+
19
+ validate_configuration
20
+ end
21
+
22
+ def add_tool(name, &block)
23
+ unless block_given?
24
+ raise ArgumentError, "Block required for tool '#{name}'"
25
+ end
26
+
27
+ @tools[name.to_s] = block
28
+ end
29
+
30
+ def start(io_in = $stdin, io_out = $stdout)
31
+ @running = true
32
+
33
+ case @type
34
+ when "stdio"
35
+ start_stdio_server(io_in, io_out)
36
+ when "sse"
37
+ start_sse_server
38
+ when "enhanced_sse"
39
+ start_enhanced_sse_server
40
+ else
41
+ raise ArgumentError, "Unknown server type: #{@type}"
42
+ end
43
+ end
44
+
45
+ def stop
46
+ @running = false
47
+ @sse_server.stop if @sse_server
48
+ @enhanced_server.stop if @enhanced_server
49
+ end
50
+
51
+ def list_tools
52
+ tool_list = @tools.keys.map do |tool_name|
53
+ {
54
+ name: tool_name,
55
+ description: "Tool: #{tool_name}",
56
+ inputSchema: {
57
+ type: "object",
58
+ properties: {},
59
+ required: []
60
+ }
61
+ }
62
+ end
63
+
64
+ {
65
+ tools: tool_list
66
+ }
67
+ end
68
+
69
+ def call_tool(name, arguments = {})
70
+ tool_name = name.to_s
71
+
72
+ unless @tools.key?(tool_name)
73
+ raise Error, "Tool '#{tool_name}' not found"
74
+ end
75
+
76
+ begin
77
+ result = @tools[tool_name].call(arguments)
78
+
79
+ # Ensure result has the expected MCP format
80
+ if result.is_a?(Hash) && result.key?(:content)
81
+ result
82
+ else
83
+ # Wrap simple results in MCP format
84
+ {
85
+ content: [{ type: "text", text: result.to_s }]
86
+ }
87
+ end
88
+ rescue => e
89
+ raise Error, "Error executing tool '#{tool_name}': #{e.message}"
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def validate_configuration
96
+ case @type
97
+ when "stdio"
98
+ # No additional validation needed for stdio
99
+ when "sse", "enhanced_sse"
100
+ if @port.nil?
101
+ raise ArgumentError, "Port is required for SSE server type"
102
+ end
103
+ unless @port.is_a?(Integer) && @port > 0 && @port < 65536
104
+ raise ArgumentError, "Port must be a valid integer between 1 and 65535"
105
+ end
106
+ else
107
+ raise ArgumentError, "Server type must be 'stdio', 'sse', or 'enhanced_sse'"
108
+ end
109
+ end
110
+
111
+ def start_stdio_server(io_in, io_out)
112
+ @io_in = io_in
113
+ @io_out = io_out
114
+
115
+ puts "MCP Server '#{@name}' v#{@version} (stdio) starting..."
116
+ puts "Available tools: #{@tools.keys.join(', ')}"
117
+ puts "Ready to accept JSON-RPC requests on stdin..."
118
+
119
+ process_stdio_requests
120
+ end
121
+
122
+ def start_sse_server
123
+ puts "MCP Server '#{@name}' v#{@version} (SSE) starting on port #{@port}..."
124
+ puts "Available tools: #{@tools.keys.join(', ')}"
125
+
126
+ app = create_sse_app
127
+ @sse_server = Puma::Server.new(app)
128
+ @sse_server.add_tcp_listener("0.0.0.0", @port)
129
+
130
+ puts "SSE Server ready at http://localhost:#{@port}"
131
+ puts "MCP SSE Protocol Endpoints:"
132
+ puts " GET /sse - Get message endpoint (returns 'event: endpoint\\ndata: /mcp/message')"
133
+ puts " POST /mcp/message - Send JSON-RPC requests and receive SSE responses"
134
+ puts " GET /health - Health check"
135
+
136
+ @sse_server.run.join
137
+ end
138
+
139
+ def start_enhanced_sse_server
140
+ puts "MCP Server '#{@name}' v#{@version} (Enhanced SSE) starting on port #{@port}..."
141
+ puts "Available tools: #{@tools.keys.join(', ')}"
142
+
143
+ # Create Enhanced SSE server app
144
+ app = create_enhanced_sse_app
145
+ @enhanced_server = Puma::Server.new(app)
146
+ @enhanced_server.add_tcp_listener("0.0.0.0", @port)
147
+
148
+ puts "Enhanced SSE Server ready at http://localhost:#{@port}"
149
+ puts "MCP SSE Protocol Endpoints (Enhanced):"
150
+ puts " GET /sse - Get message endpoint (returns 'event: endpoint\\ndata: /mcp/message')"
151
+ puts " POST /mcp/message - Send JSON-RPC requests and receive SSE responses"
152
+ puts " GET /sse/events - Advanced SSE endpoint with connection management"
153
+ puts " POST /mcp/broadcast - Broadcast to all connected SSE clients"
154
+ puts " GET /ws/connect - WebSocket-like connection (long polling)"
155
+ puts " POST /ws/send/:id - Send message to specific connection"
156
+ puts " GET /health - Health check with detailed info"
157
+ puts " GET /connections - View active connections"
158
+
159
+ # Start the Enhanced SSE server
160
+ @enhanced_server.run.join
161
+ end
162
+
163
+ def process_stdio_requests
164
+ while @running
165
+ begin
166
+ line = @io_in.gets
167
+ break unless line
168
+
169
+ line = line.strip
170
+ next if line.empty?
171
+
172
+ request = JSON.parse(line)
173
+ response = handle_request(request)
174
+
175
+ @io_out.puts response.to_json
176
+ @io_out.flush
177
+ rescue JSON::ParserError => e
178
+ send_stdio_error_response(nil, -32700, "Parse error: #{e.message}")
179
+ rescue => e
180
+ send_stdio_error_response(nil, -32603, "Internal error: #{e.message}")
181
+ end
182
+ end
183
+ end
184
+
185
+ def create_sse_app
186
+ server_instance = self
187
+ connections = []
188
+ connections_mutex = Mutex.new
189
+
190
+ Sinatra.new do
191
+ set :server, :puma
192
+ set :bind, '0.0.0.0'
193
+ set :port, server_instance.port
194
+
195
+ # Enable CORS
196
+ before do
197
+ headers 'Access-Control-Allow-Origin' => '*',
198
+ 'Access-Control-Allow-Methods' => ['GET', 'POST', 'OPTIONS'],
199
+ 'Access-Control-Allow-Headers' => 'Content-Type'
200
+ end
201
+
202
+ options '*' do
203
+ 200
204
+ end
205
+
206
+ # SSE endpoint - returns the message endpoint for subsequent requests
207
+ get '/sse' do
208
+ content_type 'text/event-stream'
209
+ headers 'Cache-Control' => 'no-cache',
210
+ 'Connection' => 'keep-alive',
211
+ 'X-Accel-Buffering' => 'no'
212
+
213
+ stream(:keep_open) do |out|
214
+ connections_mutex.synchronize do
215
+ connections << out
216
+ end
217
+
218
+ # Send endpoint event as per MCP SSE protocol
219
+ out << "event: endpoint\ndata: /mcp/message\n\n"
220
+
221
+ # Heartbeat timer to keep connection alive
222
+ heartbeat = ::EventMachine.add_periodic_timer(15) do
223
+ begin
224
+ out << "event: ping\n"
225
+ out << "data: #{Time.now.to_i}\n\n"
226
+ rescue => e
227
+ puts "Heartbeat error: #{e.message}"
228
+ end
229
+ end
230
+
231
+ # Clean up when connection closes
232
+ out.callback do
233
+ connections_mutex.synchronize do
234
+ connections.delete(out)
235
+ ::EventMachine.cancel_timer(heartbeat)
236
+ end
237
+ end
238
+
239
+ out.errback do
240
+ connections_mutex.synchronize do
241
+ connections.delete(out)
242
+ ::EventMachine.cancel_timer(heartbeat)
243
+ end
244
+ end
245
+ end
246
+ end
247
+
248
+ # MCP message endpoint - handles POST requests and returns SSE responses
249
+ post '/mcp/message' do
250
+ content_type 'text/event-stream'
251
+ headers 'Cache-Control' => 'no-cache',
252
+ 'Connection' => 'keep-alive',
253
+ 'X-Accel-Buffering' => 'no'
254
+
255
+ stream(:keep_open) do |out|
256
+ begin
257
+ request_data = JSON.parse(request.body.read)
258
+ response = server_instance.send(:handle_request, request_data)
259
+
260
+ # Return JSON-RPC response in SSE format
261
+ out << "data: #{response.to_json}\n\n"
262
+ rescue JSON::ParserError => e
263
+ error_response = {
264
+ jsonrpc: "2.0",
265
+ id: nil,
266
+ error: {
267
+ code: -32700,
268
+ message: "Parse error: #{e.message}"
269
+ }
270
+ }
271
+ out << "data: #{error_response.to_json}\n\n"
272
+ rescue => e
273
+ error_response = {
274
+ jsonrpc: "2.0",
275
+ id: nil,
276
+ error: {
277
+ code: -32603,
278
+ message: "Internal error: #{e.message}"
279
+ }
280
+ }
281
+ out << "data: #{error_response.to_json}\n\n"
282
+ ensure
283
+ out.close
284
+ end
285
+ end
286
+ end
287
+
288
+ # Health check endpoint
289
+ get '/health' do
290
+ content_type 'application/json'
291
+ {
292
+ status: 'ok',
293
+ server: server_instance.name,
294
+ version: server_instance.version,
295
+ type: server_instance.type,
296
+ tools_count: server_instance.tools.size,
297
+ protocol: 'MCP SSE',
298
+ active_connections: connections.length,
299
+ endpoints: {
300
+ sse: '/sse',
301
+ message: '/mcp/message'
302
+ }
303
+ }.to_json
304
+ end
305
+ end
306
+ end
307
+
308
+ def create_enhanced_sse_app
309
+ server_instance = self
310
+ sse_connections = []
311
+ ws_connections = {}
312
+ connection_mutex = Mutex.new
313
+
314
+ Sinatra.new do
315
+ set :server, :puma
316
+ set :bind, '0.0.0.0'
317
+ set :port, server_instance.port
318
+
319
+ # Enable CORS
320
+ before do
321
+ headers 'Access-Control-Allow-Origin' => '*',
322
+ 'Access-Control-Allow-Methods' => ['GET', 'POST', 'OPTIONS'],
323
+ 'Access-Control-Allow-Headers' => 'Content-Type'
324
+ end
325
+
326
+ options '*' do
327
+ 200
328
+ end
329
+
330
+ # Standard SSE endpoint
331
+ get '/sse' do
332
+ content_type 'text/event-stream'
333
+ headers 'Cache-Control' => 'no-cache',
334
+ 'Connection' => 'keep-alive'
335
+
336
+ response = "event: endpoint\ndata: /mcp/message\n\n"
337
+ response
338
+ end
339
+
340
+ # Enhanced SSE endpoint with connection management
341
+ get '/sse/events' do
342
+ content_type 'text/event-stream'
343
+ headers 'Cache-Control' => 'no-cache',
344
+ 'Connection' => 'keep-alive',
345
+ 'X-Accel-Buffering' => 'no'
346
+
347
+ connection_id = SecureRandom.hex(8)
348
+
349
+ stream do |out|
350
+ connection_mutex.synchronize do
351
+ sse_connections << {
352
+ id: connection_id,
353
+ response: out,
354
+ created_at: Time.now
355
+ }
356
+ end
357
+
358
+ begin
359
+ out << "event: endpoint\n"
360
+ out << "data: /mcp/message\n\n"
361
+
362
+ out << "event: connected\n"
363
+ out << "data: #{connection_id}\n\n"
364
+
365
+ loop do
366
+ sleep 30
367
+ out << "event: heartbeat\n"
368
+ out << "data: #{Time.now.to_i}\n\n"
369
+ end
370
+ rescue => e
371
+ puts "SSE connection error: #{e.message}"
372
+ ensure
373
+ connection_mutex.synchronize do
374
+ sse_connections.reject! { |conn| conn[:id] == connection_id }
375
+ end
376
+ end
377
+ end
378
+ end
379
+
380
+ # MCP message endpoint
381
+ post '/mcp/message' do
382
+ content_type 'text/event-stream'
383
+ headers 'Cache-Control' => 'no-cache',
384
+ 'Connection' => 'keep-alive'
385
+
386
+ begin
387
+ request_data = JSON.parse(request.body.read)
388
+ response = server_instance.send(:handle_request, request_data)
389
+
390
+ sse_response = "data: #{response.to_json}\n\n"
391
+ sse_response
392
+ rescue JSON::ParserError => e
393
+ error_response = {
394
+ jsonrpc: "2.0",
395
+ id: nil,
396
+ error: {
397
+ code: -32700,
398
+ message: "Parse error: #{e.message}"
399
+ }
400
+ }
401
+ "data: #{error_response.to_json}\n\n"
402
+ rescue => e
403
+ error_response = {
404
+ jsonrpc: "2.0",
405
+ id: nil,
406
+ error: {
407
+ code: -32603,
408
+ message: "Internal error: #{e.message}"
409
+ }
410
+ }
411
+ "data: #{error_response.to_json}\n\n"
412
+ end
413
+ end
414
+
415
+ # Broadcast endpoint - improved connection management
416
+ post '/mcp/broadcast' do
417
+ content_type 'application/json'
418
+
419
+ begin
420
+ request_data = JSON.parse(request.body.read)
421
+ response = server_instance.send(:handle_request, request_data)
422
+
423
+ broadcasted_count = 0
424
+ failed_connections = []
425
+
426
+ connection_mutex.synchronize do
427
+ sse_connections.each do |connection|
428
+ begin
429
+ connection[:response] << "event: broadcast\n"
430
+ connection[:response] << "data: #{response.to_json}\n\n"
431
+ broadcasted_count += 1
432
+ rescue => e
433
+ puts "Broadcast error to connection #{connection[:id]}: #{e.message}"
434
+ failed_connections << connection[:id]
435
+ end
436
+ end
437
+
438
+ # Clean up failed connections
439
+ sse_connections.reject! { |conn| failed_connections.include?(conn[:id]) }
440
+ end
441
+
442
+ {
443
+ status: 'broadcasted',
444
+ message: 'Message sent to connected clients',
445
+ clients: broadcasted_count,
446
+ failed: failed_connections.length
447
+ }.to_json
448
+ rescue => e
449
+ { status: 'error', message: e.message }.to_json
450
+ end
451
+ end
452
+
453
+ # WebSocket simulation - improved with proper cleanup
454
+ get '/ws/connect' do
455
+ content_type 'text/event-stream'
456
+ headers 'Cache-Control' => 'no-cache',
457
+ 'Connection' => 'keep-alive',
458
+ 'X-Accel-Buffering' => 'no'
459
+
460
+ connection_id = SecureRandom.hex(8)
461
+
462
+ stream(:keep_open) do |out|
463
+ connection_mutex.synchronize do
464
+ ws_connections[connection_id] = out
465
+ end
466
+
467
+ # Heartbeat for WebSocket simulation
468
+ heartbeat = ::EventMachine.add_periodic_timer(15) do
469
+ begin
470
+ out << "event: ping\n"
471
+ out << "data: #{Time.now.to_i}\n\n"
472
+ rescue => e
473
+ puts "WebSocket heartbeat error: #{e.message}"
474
+ end
475
+ end
476
+
477
+ out << "event: ws_connected\n"
478
+ out << "data: #{connection_id}\n\n"
479
+
480
+ out.callback do
481
+ connection_mutex.synchronize do
482
+ ws_connections.delete(connection_id)
483
+ ::EventMachine.cancel_timer(heartbeat)
484
+ end
485
+ end
486
+
487
+ out.errback do
488
+ connection_mutex.synchronize do
489
+ ws_connections.delete(connection_id)
490
+ ::EventMachine.cancel_timer(heartbeat)
491
+ end
492
+ end
493
+ end
494
+ end
495
+
496
+ # Send to WebSocket connection
497
+ post '/ws/send/:connection_id' do
498
+ connection_id = params[:connection_id]
499
+
500
+ connection_mutex.synchronize do
501
+ if ws_connections[connection_id]
502
+ begin
503
+ request_data = JSON.parse(request.body.read)
504
+ response = server_instance.send(:handle_request, request_data)
505
+
506
+ ws_connections[connection_id] << "event: ws_message\n"
507
+ ws_connections[connection_id] << "data: #{response.to_json}\n\n"
508
+
509
+ { status: 'sent', connection_id: connection_id }.to_json
510
+ rescue => e
511
+ { status: 'error', message: e.message }.to_json
512
+ ensure
513
+ # Clean up failed connection
514
+ ws_connections.delete(connection_id) if e
515
+ end
516
+ else
517
+ status 404
518
+ { status: 'error', message: 'Connection not found' }.to_json
519
+ end
520
+ end
521
+ end
522
+
523
+ # Enhanced health check
524
+ get '/health' do
525
+ content_type 'application/json'
526
+ {
527
+ status: 'ok',
528
+ server: server_instance.name,
529
+ version: server_instance.version,
530
+ type: 'enhanced_sse',
531
+ tools_count: server_instance.tools.size,
532
+ protocol: 'MCP SSE (Enhanced Sinatra)',
533
+ endpoints: {
534
+ sse: '/sse',
535
+ sse_events: '/sse/events',
536
+ message: '/mcp/message',
537
+ broadcast: '/mcp/broadcast',
538
+ ws_connect: '/ws/connect',
539
+ ws_send: '/ws/send/:connection_id'
540
+ },
541
+ connections: {
542
+ sse: sse_connections.length,
543
+ ws: ws_connections.length
544
+ },
545
+ uptime: Time.now.to_i
546
+ }.to_json
547
+ end
548
+
549
+ # Connection status - improved thread safety
550
+ get '/connections' do
551
+ content_type 'application/json'
552
+
553
+ connection_mutex.synchronize do
554
+ {
555
+ sse_connections: sse_connections.map { |conn|
556
+ {
557
+ id: conn[:id],
558
+ created_at: conn[:created_at].iso8601,
559
+ age_seconds: (Time.now - conn[:created_at]).to_i
560
+ }
561
+ },
562
+ ws_connections: ws_connections.keys,
563
+ total: sse_connections.length + ws_connections.length
564
+ }.to_json
565
+ end
566
+ end
567
+ end
568
+ end
569
+
570
+ def handle_request(request)
571
+ request_id = request["id"]
572
+ method = request["method"]
573
+ params = request["params"] || {}
574
+
575
+ case method
576
+ when "tools/list"
577
+ {
578
+ jsonrpc: "2.0",
579
+ id: request_id,
580
+ result: list_tools
581
+ }
582
+ when "tools/call"
583
+ tool_name = params["name"]
584
+ arguments = params["arguments"] || {}
585
+
586
+ begin
587
+ result = call_tool(tool_name, arguments)
588
+ {
589
+ jsonrpc: "2.0",
590
+ id: request_id,
591
+ result: result
592
+ }
593
+ rescue Error => e
594
+ {
595
+ jsonrpc: "2.0",
596
+ id: request_id,
597
+ error: {
598
+ code: -32000,
599
+ message: e.message
600
+ }
601
+ }
602
+ end
603
+ else
604
+ {
605
+ jsonrpc: "2.0",
606
+ id: request_id,
607
+ error: {
608
+ code: -32601,
609
+ message: "Method not found: #{method}"
610
+ }
611
+ }
612
+ end
613
+ end
614
+
615
+ def send_stdio_error_response(request_id, code, message)
616
+ response = {
617
+ jsonrpc: "2.0",
618
+ id: request_id,
619
+ error: {
620
+ code: code,
621
+ message: message
622
+ }
623
+ }
624
+
625
+ @io_out.puts response.to_json
626
+ @io_out.flush
627
+ end
628
+ end
629
+ end
@@ -86,5 +86,8 @@ module MCP
86
86
  read_response(request_id)
87
87
  write_request({ jsonrpc: "2.0", method: "notifications/initialized" }.to_json)
88
88
  end
89
+
90
+ def stop
91
+ end
89
92
  end
90
93
  end
@@ -31,8 +31,10 @@ module MCP
31
31
  @running = false
32
32
  @stdout_writer.close
33
33
  @stderr_writer.close
34
- @reader.join if @reader
34
+ @reader.join if @reader && !@pending_requests.empty?
35
35
  @server.stop
36
+ @server.io.stdin.close
37
+ @server.io.stdout.close
36
38
  @stdout.close
37
39
  @stderr.close
38
40
  end
data/lib/mcp.rb CHANGED
@@ -3,7 +3,8 @@ require File.expand_path("../mcp/client", __FILE__)
3
3
  require File.expand_path("../mcp/stdio_client", __FILE__)
4
4
  require File.expand_path("../mcp/sse_client", __FILE__)
5
5
  require File.expand_path("../mcp/convert", __FILE__)
6
+ require File.expand_path("../mcp/server", __FILE__)
6
7
 
7
8
  module MCP
8
9
  LATEST_PROTOCOL_VERSION = "2024-11-05".freeze
9
- end
10
+ end