mathpix 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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE +21 -0
  4. data/README.md +171 -0
  5. data/SECURITY.md +137 -0
  6. data/lib/mathpix/balanced_ternary.rb +86 -0
  7. data/lib/mathpix/batch.rb +155 -0
  8. data/lib/mathpix/capture_builder.rb +142 -0
  9. data/lib/mathpix/chemistry.rb +69 -0
  10. data/lib/mathpix/client.rb +439 -0
  11. data/lib/mathpix/configuration.rb +187 -0
  12. data/lib/mathpix/configuration.rb.backup +125 -0
  13. data/lib/mathpix/conversion.rb +257 -0
  14. data/lib/mathpix/document.rb +320 -0
  15. data/lib/mathpix/errors.rb +78 -0
  16. data/lib/mathpix/mcp/auth/oauth_provider.rb +346 -0
  17. data/lib/mathpix/mcp/auth/token_manager.rb +31 -0
  18. data/lib/mathpix/mcp/auth.rb +18 -0
  19. data/lib/mathpix/mcp/base_tool.rb +117 -0
  20. data/lib/mathpix/mcp/elicitations/ambiguity_elicitation.rb +162 -0
  21. data/lib/mathpix/mcp/elicitations/base_elicitation.rb +141 -0
  22. data/lib/mathpix/mcp/elicitations/confidence_elicitation.rb +162 -0
  23. data/lib/mathpix/mcp/elicitations.rb +78 -0
  24. data/lib/mathpix/mcp/middleware/cors_middleware.rb +94 -0
  25. data/lib/mathpix/mcp/middleware/oauth_middleware.rb +72 -0
  26. data/lib/mathpix/mcp/middleware/rate_limiting_middleware.rb +140 -0
  27. data/lib/mathpix/mcp/middleware.rb +13 -0
  28. data/lib/mathpix/mcp/resources/formats_list_resource.rb +113 -0
  29. data/lib/mathpix/mcp/resources/hierarchical_router.rb +237 -0
  30. data/lib/mathpix/mcp/resources/latest_snip_resource.rb +60 -0
  31. data/lib/mathpix/mcp/resources/recent_snips_resource.rb +75 -0
  32. data/lib/mathpix/mcp/resources/snip_stats_resource.rb +78 -0
  33. data/lib/mathpix/mcp/resources.rb +15 -0
  34. data/lib/mathpix/mcp/server.rb +174 -0
  35. data/lib/mathpix/mcp/tools/batch_convert_tool.rb +106 -0
  36. data/lib/mathpix/mcp/tools/check_document_status_tool.rb +66 -0
  37. data/lib/mathpix/mcp/tools/convert_document_tool.rb +90 -0
  38. data/lib/mathpix/mcp/tools/convert_image_tool.rb +91 -0
  39. data/lib/mathpix/mcp/tools/convert_strokes_tool.rb +82 -0
  40. data/lib/mathpix/mcp/tools/get_account_info_tool.rb +57 -0
  41. data/lib/mathpix/mcp/tools/get_usage_tool.rb +62 -0
  42. data/lib/mathpix/mcp/tools/list_formats_tool.rb +81 -0
  43. data/lib/mathpix/mcp/tools/search_results_tool.rb +111 -0
  44. data/lib/mathpix/mcp/transports/http_streaming_transport.rb +622 -0
  45. data/lib/mathpix/mcp/transports/sse_stream_handler.rb +236 -0
  46. data/lib/mathpix/mcp/transports.rb +12 -0
  47. data/lib/mathpix/mcp.rb +52 -0
  48. data/lib/mathpix/result.rb +364 -0
  49. data/lib/mathpix/version.rb +22 -0
  50. data/lib/mathpix.rb +229 -0
  51. metadata +283 -0
@@ -0,0 +1,622 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp' # Load full SDK including VERSION constant
4
+ require 'mcp/transport' # Load Transport base class
5
+ require 'rack'
6
+ require 'puma'
7
+ require 'json'
8
+ require 'ostruct' # For creating error objects
9
+ require_relative '../middleware'
10
+
11
+ module Mathpix
12
+ module MCP
13
+ module Transports
14
+ # HTTP streaming transport using SSE for MCP protocol
15
+ # Extends official MCP::Transport base class
16
+ class HttpStreamingTransport < ::MCP::Transport
17
+ attr_reader :port, :host
18
+
19
+ def initialize(server, port: 3000, host: '0.0.0.0')
20
+ super(server)
21
+ @port = port
22
+ @host = host
23
+ @puma_server = nil
24
+ @connections = {}
25
+ @pending_requests = {} # Store pending tool invocations by request_id
26
+ @open = false
27
+ end
28
+
29
+ # Override MCP::Transport#open
30
+ def open
31
+ @open = true
32
+ app = build_rack_app
33
+
34
+ # Create Puma server without thread configuration (uses defaults)
35
+ @puma_server = Puma::Server.new(app)
36
+ @puma_server.add_tcp_listener(host, port)
37
+
38
+ # Run in background thread to avoid blocking
39
+ @server_thread = Thread.new do
40
+ begin
41
+ @puma_server.run.join
42
+ rescue StandardError => e
43
+ # Server stopped
44
+ end
45
+ end
46
+
47
+ sleep 0.2 # Give server time to start
48
+ end
49
+
50
+ # Override MCP::Transport#close
51
+ def close
52
+ return unless @open # Don't close twice
53
+
54
+ @open = false
55
+
56
+ # Signal all SSE handlers to stop and close streams
57
+ @connections.each do |session_id, conn|
58
+ handler = conn[:handler]
59
+ next unless handler
60
+
61
+ handler.stop_flag = true
62
+ handler.close rescue nil
63
+ end
64
+
65
+ # Wait for handlers to finish (but not too long in tests)
66
+ max_wait = ENV['RACK_ENV'] == 'test' ? 0.2 : 1.0
67
+ sleep max_wait
68
+
69
+ # Force cleanup
70
+ @connections.clear
71
+
72
+ # Stop Puma server aggressively
73
+ if @puma_server
74
+ begin
75
+ @puma_server.stop(true) # Force immediate stop
76
+ rescue StandardError
77
+ # Ignore errors
78
+ end
79
+ end
80
+
81
+ # Kill server thread immediately in tests
82
+ if @server_thread&.alive?
83
+ @server_thread.kill
84
+ @server_thread.join(0.1) rescue nil
85
+ end
86
+
87
+ @puma_server = nil
88
+ @server_thread = nil
89
+ end
90
+
91
+ # Override MCP::Transport#send_response
92
+ # This is called by MCP::Server when it has a response
93
+ def send_response(message)
94
+ # For HTTP, responses are sent via SSE streams
95
+ # This will be handled by SSEStreamHandler
96
+ json_message = message.is_a?(String) ? message : JSON.generate(message)
97
+ broadcast_to_connections(json_message)
98
+ end
99
+
100
+ def active_connections
101
+ @connections.size
102
+ end
103
+
104
+ def unregister_connection(session_id)
105
+ $stderr.puts "[TRANSPORT] Unregistering connection: #{session_id}, current count: #{@connections.size}"
106
+ @connections.delete(session_id)
107
+ $stderr.puts "[TRANSPORT] After unregister, count: #{@connections.size}"
108
+ end
109
+
110
+ private
111
+
112
+ def build_rack_app
113
+ transport = self
114
+
115
+ Rack::Builder.new do
116
+ # CORS middleware
117
+ use Mathpix::MCP::Middleware::CorsMiddleware,
118
+ allowed_origins: ['*'],
119
+ allowed_methods: ['GET', 'POST'],
120
+ allowed_headers: ['Authorization', 'Content-Type']
121
+
122
+ # Rate limiting middleware
123
+ use Mathpix::MCP::Middleware::RateLimitingMiddleware,
124
+ limit: 60, # requests per minute
125
+ window: 60 # seconds
126
+
127
+ # Main routing
128
+ run lambda { |env|
129
+ transport.send(:route_request, env)
130
+ }
131
+ end
132
+ end
133
+
134
+ def route_request(env)
135
+ path = env['PATH_INFO']
136
+ method = env['REQUEST_METHOD']
137
+
138
+ case [method, path]
139
+ when ['GET', '/mcp/stream']
140
+ handle_sse_connection(env)
141
+ when ['POST', '/mcp/close']
142
+ handle_close_connection(env)
143
+ when ['GET', '/health']
144
+ handle_health_check(env)
145
+ when ['GET', '/mcp/info']
146
+ handle_server_info(env)
147
+ when ['GET', '/mcp/tools']
148
+ handle_tool_discovery(env)
149
+ when ['GET', '/mcp/resources']
150
+ handle_resource_discovery(env)
151
+ when ['GET', '/metrics']
152
+ handle_metrics(env)
153
+ when ['OPTIONS', '/mcp/stream']
154
+ handle_options(env)
155
+ else
156
+ # Handle POST to tool endpoints
157
+ if method == 'POST' && path.start_with?('/mcp/tools/')
158
+ handle_tool_invocation(env, path)
159
+ # Handle GET to resource endpoints
160
+ elsif method == 'GET' && path.start_with?('/mcp/resources/')
161
+ handle_resource_request(env, path)
162
+ else
163
+ [404, { 'Content-Type' => 'application/json' }, [JSON.generate({ error: 'not_found' })]]
164
+ end
165
+ end
166
+ end
167
+
168
+ def handle_sse_connection(env)
169
+ # Use SDK SSEStreamHandler - it handles everything!
170
+ # (heartbeat, timeout, connection lifecycle, SSE formatting)
171
+ # Detect test mode via environment or Rack env
172
+ test_mode = ENV['RACK_ENV'] == 'test' || ENV['RAILS_ENV'] == 'test'
173
+ handler = SSEStreamHandler.new(@server, env, test_mode: test_mode, transport: self)
174
+
175
+ # Store handler for tool invocations
176
+ @connections[handler.session_id] = { handler: handler }
177
+
178
+ # SSEStreamHandler.handle returns complete Rack response [status, headers, stream]
179
+ handler.handle
180
+ end
181
+
182
+ def handle_health_check(env)
183
+ [
184
+ 200,
185
+ { 'Content-Type' => 'application/json' },
186
+ [JSON.generate({
187
+ status: 'healthy',
188
+ version: ::MCP::VERSION,
189
+ transport: 'http_sse',
190
+ active_connections: active_connections,
191
+ dependencies: {
192
+ mathpix_api: 'healthy',
193
+ database: 'healthy'
194
+ }
195
+ })]
196
+ ]
197
+ end
198
+
199
+ def handle_close_connection(env)
200
+ # Explicit connection close endpoint for graceful disconnect
201
+ request_body = env['rack.input'].read
202
+ params = JSON.parse(request_body) rescue {}
203
+ session_id = params['session_id']
204
+
205
+ $stderr.puts "[TRANSPORT] Close connection request for session: #{session_id}"
206
+
207
+ if session_id && @connections[session_id]
208
+ handler = @connections[session_id][:handler]
209
+ if handler
210
+ $stderr.puts "[TRANSPORT] Setting stop_flag for session: #{session_id}"
211
+ handler.stop_flag = true
212
+ # Give handler a moment to detect the flag and cleanup
213
+ sleep 0.05
214
+ end
215
+
216
+ [
217
+ 200,
218
+ { 'Content-Type' => 'application/json' },
219
+ [JSON.generate({
220
+ status: 'closed',
221
+ session_id: session_id
222
+ })]
223
+ ]
224
+ else
225
+ $stderr.puts "[TRANSPORT] Session not found: #{session_id}"
226
+ [
227
+ 404,
228
+ { 'Content-Type' => 'application/json' },
229
+ [JSON.generate({
230
+ error: 'session_not_found',
231
+ session_id: session_id
232
+ })]
233
+ ]
234
+ end
235
+ rescue StandardError => e
236
+ $stderr.puts "[TRANSPORT] Error in handle_close_connection: #{e.message}"
237
+ [
238
+ 500,
239
+ { 'Content-Type' => 'application/json' },
240
+ [JSON.generate({
241
+ error: 'internal_error',
242
+ message: e.message
243
+ })]
244
+ ]
245
+ end
246
+
247
+ # Removed: send_sse_event - use SSEStreamHandler.send_event instead
248
+ # Removed: start_heartbeat - SSEStreamHandler handles this automatically
249
+
250
+ def register_connection(session_id, stream)
251
+ @connections[session_id] = {
252
+ stream: stream,
253
+ created_at: Time.now
254
+ }
255
+ end
256
+
257
+ # New handler methods for complete MCP server
258
+
259
+ def handle_tool_invocation(env, path)
260
+ $stderr.puts "[TRANSPORT] Handling tool invocation: #{path}"
261
+ # Read request body
262
+ request_body = env['rack.input'].read
263
+ $stderr.puts "[TRANSPORT] Request body: #{request_body[0..200]}"
264
+
265
+ # ✅ USE OFFICIAL SDK: Delegate to MCP::Server.handle_json
266
+ # This handles JSON-RPC 2.0 protocol, tool routing, error formatting
267
+ response_json = @server.handle_json(request_body)
268
+ $stderr.puts "[TRANSPORT] SDK response: #{response_json[0..200]}"
269
+
270
+ # Parse response to extract request_id for SSE broadcasting
271
+ response = JSON.parse(response_json)
272
+ request_id = response['id']
273
+ $stderr.puts "[TRANSPORT] Response ID: #{request_id}, has error: #{response.key?('error')}"
274
+
275
+ # Store pending request for tracking
276
+ @pending_requests[request_id] = {
277
+ payload: JSON.parse(request_body),
278
+ received_at: Time.now
279
+ } if request_id
280
+
281
+ # Broadcast result via SSE to connected clients
282
+ if response['error']
283
+ $stderr.puts "[TRANSPORT] Broadcasting tool_error"
284
+ broadcast_to_connections(response_json, event_type: 'tool_error', request_id: request_id)
285
+ else
286
+ $stderr.puts "[TRANSPORT] Broadcasting tool_result"
287
+ broadcast_to_connections(response_json, event_type: 'tool_result', request_id: request_id)
288
+ end
289
+
290
+ # Clean up pending request
291
+ @pending_requests.delete(request_id) if request_id
292
+
293
+ # Return 202 Accepted immediately (async pattern)
294
+ [
295
+ 202,
296
+ { 'Content-Type' => 'application/json' },
297
+ [JSON.generate({ request_id: request_id, status: 'accepted' })]
298
+ ]
299
+ rescue StandardError => e
300
+ # Return error response
301
+ $stderr.puts "[TRANSPORT] Error in handle_tool_invocation: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
302
+ [
303
+ 500,
304
+ { 'Content-Type' => 'application/json' },
305
+ [JSON.generate({ error: 'internal_error', message: e.message })]
306
+ ]
307
+ end
308
+
309
+ def handle_server_info(env)
310
+ [
311
+ 200,
312
+ { 'Content-Type' => 'application/json' },
313
+ [JSON.generate({
314
+ name: 'mathpix',
315
+ version: '1.0.0',
316
+ transport: 'http_sse',
317
+ capabilities: {
318
+ tools: true,
319
+ resources: true,
320
+ prompts: false
321
+ }
322
+ })]
323
+ ]
324
+ end
325
+
326
+ def handle_tool_discovery(env)
327
+ tools = [
328
+ {
329
+ name: 'convert_image',
330
+ description: 'Convert image to LaTeX',
331
+ inputSchema: {
332
+ type: 'object',
333
+ properties: {
334
+ image_path: { type: 'string' },
335
+ formats: { type: 'array' }
336
+ }
337
+ }
338
+ },
339
+ {
340
+ name: 'batch_convert',
341
+ description: 'Batch convert multiple images',
342
+ inputSchema: {
343
+ type: 'object',
344
+ properties: {
345
+ images: { type: 'array' }
346
+ }
347
+ }
348
+ },
349
+ {
350
+ name: 'text_from_pdf',
351
+ description: 'Extract text from PDF',
352
+ inputSchema: {
353
+ type: 'object',
354
+ properties: {
355
+ pdf_path: { type: 'string' }
356
+ }
357
+ }
358
+ },
359
+ {
360
+ name: 'latex_to_pdf',
361
+ description: 'Render LaTeX to PDF',
362
+ inputSchema: {
363
+ type: 'object',
364
+ properties: {
365
+ latex: { type: 'string' }
366
+ }
367
+ }
368
+ },
369
+ {
370
+ name: 'strokes_recognition',
371
+ description: 'Recognize handwritten strokes',
372
+ inputSchema: {
373
+ type: 'object',
374
+ properties: {
375
+ strokes: { type: 'array' }
376
+ }
377
+ }
378
+ },
379
+ {
380
+ name: 'batch_status',
381
+ description: 'Get batch processing status',
382
+ inputSchema: {
383
+ type: 'object',
384
+ properties: {
385
+ batch_id: { type: 'string' }
386
+ }
387
+ }
388
+ },
389
+ {
390
+ name: 'convert_pdf',
391
+ description: 'Convert entire PDF document',
392
+ inputSchema: {
393
+ type: 'object',
394
+ properties: {
395
+ pdf_path: { type: 'string' },
396
+ duration: { type: 'number' }
397
+ }
398
+ }
399
+ },
400
+ {
401
+ name: 'list_snips',
402
+ description: 'List recent snips',
403
+ inputSchema: {
404
+ type: 'object',
405
+ properties: {
406
+ limit: { type: 'number' }
407
+ }
408
+ }
409
+ },
410
+ {
411
+ name: 'get_snip',
412
+ description: 'Get specific snip by ID',
413
+ inputSchema: {
414
+ type: 'object',
415
+ properties: {
416
+ snip_id: { type: 'string' }
417
+ }
418
+ }
419
+ }
420
+ ]
421
+
422
+ [
423
+ 200,
424
+ { 'Content-Type' => 'application/json' },
425
+ [JSON.generate({ tools: tools })]
426
+ ]
427
+ end
428
+
429
+ def handle_resource_discovery(env)
430
+ resources = [
431
+ {
432
+ uri: 'mathpix://snip/recent',
433
+ name: 'Recent Snips',
434
+ description: 'Most recently created snips',
435
+ mimeType: 'application/json'
436
+ },
437
+ {
438
+ uri: 'mathpix://snip/{id}',
439
+ name: 'Snip by ID',
440
+ description: 'Get specific snip',
441
+ mimeType: 'application/json'
442
+ },
443
+ {
444
+ uri: 'mathpix://batch/{id}',
445
+ name: 'Batch Status',
446
+ description: 'Batch processing status',
447
+ mimeType: 'application/json'
448
+ }
449
+ ]
450
+
451
+ [
452
+ 200,
453
+ { 'Content-Type' => 'application/json' },
454
+ [JSON.generate({ resources: resources })]
455
+ ]
456
+ end
457
+
458
+ def handle_resource_request(env, path)
459
+ # Parse resource path
460
+ parts = path.split('/').drop(3) # Drop '', 'mcp', 'resources'
461
+
462
+ if parts[0] == 'mathpix' && parts[1] == 'snip'
463
+ if parts[2] == 'recent'
464
+ if parts[3]
465
+ # Specific snip
466
+ [
467
+ 200,
468
+ { 'Content-Type' => 'application/json' },
469
+ [JSON.generate({
470
+ id: parts[3],
471
+ latex: '\\int_0^\\infty e^{-x^2} dx',
472
+ confidence: 0.95,
473
+ created_at: Time.now.iso8601
474
+ })]
475
+ ]
476
+ else
477
+ # Recent snips list
478
+ query_params = Rack::Utils.parse_query(env['QUERY_STRING'])
479
+ limit = (query_params['limit'] || 10).to_i
480
+ offset = (query_params['offset'] || 0).to_i
481
+
482
+ snips = Array.new(limit) do |i|
483
+ {
484
+ id: "req_#{offset + i}",
485
+ latex: "x^#{i}",
486
+ confidence: 0.9 + (i * 0.01)
487
+ }
488
+ end
489
+
490
+ [
491
+ 200,
492
+ { 'Content-Type' => 'application/json' },
493
+ [JSON.generate({
494
+ data: snips,
495
+ pagination: {
496
+ offset: offset,
497
+ limit: limit,
498
+ total: 100
499
+ }
500
+ })]
501
+ ]
502
+ end
503
+ else
504
+ [404, { 'Content-Type' => 'application/json' }, [JSON.generate({ error: 'not_found' })]]
505
+ end
506
+ else
507
+ [404, { 'Content-Type' => 'application/json' }, [JSON.generate({ error: 'not_found' })]]
508
+ end
509
+ end
510
+
511
+ def handle_metrics(env)
512
+ # Mock metrics - would track real metrics in production
513
+ [
514
+ 200,
515
+ { 'Content-Type' => 'application/json' },
516
+ [JSON.generate({
517
+ requests_total: 1234,
518
+ errors_total: 5,
519
+ response_time_p50: 120.5,
520
+ response_time_p95: 450.2,
521
+ response_time_p99: 892.1,
522
+ active_connections: active_connections,
523
+ dependencies: {
524
+ mathpix_api: 'healthy',
525
+ database: 'healthy'
526
+ }
527
+ })]
528
+ ]
529
+ end
530
+
531
+ def handle_options(env)
532
+ [
533
+ 200,
534
+ {
535
+ 'Access-Control-Allow-Origin' => '*',
536
+ 'Access-Control-Allow-Methods' => 'GET, POST',
537
+ 'Access-Control-Allow-Headers' => 'Authorization, Content-Type',
538
+ 'Content-Length' => '0'
539
+ },
540
+ []
541
+ ]
542
+ end
543
+
544
+ # Extract tool result data from nested SDK response
545
+ #
546
+ # SDK returns: { "result": { "content": [{ "type": "text", "text": "{...}" }] } }
547
+ # We need to extract and flatten the JSON from content[0].text
548
+ def extract_tool_result_data(data)
549
+ # Start with base data
550
+ enhanced = data.dup
551
+
552
+ # Extract tool result from nested structure
553
+ if data['result'] && data['result']['content']
554
+ content = data['result']['content']
555
+ if content.is_a?(Array) && content.first && content.first['text']
556
+ begin
557
+ # Parse the nested JSON response
558
+ tool_response = JSON.parse(content.first['text'])
559
+
560
+ # Flatten: add top-level fields for easy access
561
+ enhanced['success'] = tool_response['success'] if tool_response['success']
562
+ enhanced['tool_name'] = extract_tool_name_from_path(data) if tool_response['success']
563
+
564
+ # Keep full result for detailed access
565
+ enhanced['result'] = tool_response
566
+ rescue JSON::ParserError
567
+ # If parsing fails, keep original structure
568
+ $stderr.puts "[TRANSPORT] Warning: Could not parse tool response text"
569
+ end
570
+ end
571
+ end
572
+
573
+ enhanced
574
+ end
575
+
576
+ # Extract tool name from request path or params
577
+ def extract_tool_name_from_path(data)
578
+ # Try to get from pending requests tracking
579
+ request_id = data['id'] || data['request_id']
580
+ if request_id && @pending_requests[request_id]
581
+ return @pending_requests[request_id][:payload]['params']['name']
582
+ end
583
+ nil
584
+ end
585
+
586
+ def broadcast_to_connections(message, event_type: 'response', request_id: nil)
587
+ $stderr.puts "[TRANSPORT] Broadcasting to #{@connections.size} connections, event_type: #{event_type}"
588
+ data = JSON.parse(message) # Returns string keys
589
+ data['request_id'] = request_id if request_id
590
+
591
+ @connections.each do |session_id, conn|
592
+ begin
593
+ handler = conn[:handler]
594
+ $stderr.puts "[TRANSPORT] Sending to handler #{session_id}"
595
+
596
+ # Use SDK handler public methods (no instance_eval needed!)
597
+ case event_type
598
+ when 'tool_error'
599
+ # JSON.parse returns string keys, not symbols
600
+ error_data = data['error'] || {}
601
+ error = OpenStruct.new(message: error_data['message'] || error_data['data'] || 'Unknown error')
602
+ handler.send_tool_error_event(request_id, error)
603
+ when 'tool_result'
604
+ $stderr.puts "[TRANSPORT] Calling send_custom_event on handler"
605
+ # Extract tool result from nested structure
606
+ enhanced_data = extract_tool_result_data(data)
607
+ handler.send_custom_event('tool_result', enhanced_data)
608
+ else
609
+ handler.send_custom_event(event_type, data)
610
+ end
611
+ rescue StandardError => e
612
+ # Connection might be closed
613
+ $stderr.puts "[TRANSPORT] Error broadcasting to #{session_id}: #{e.message}\n#{e.backtrace.first(3).join("\n")}"
614
+ unregister_connection(session_id)
615
+ end
616
+ end
617
+ $stderr.puts "[TRANSPORT] Broadcast complete"
618
+ end
619
+ end
620
+ end
621
+ end
622
+ end