tsikol 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 (75) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +22 -0
  3. data/CONTRIBUTING.md +84 -0
  4. data/LICENSE +21 -0
  5. data/README.md +579 -0
  6. data/Rakefile +12 -0
  7. data/docs/README.md +69 -0
  8. data/docs/api/middleware.md +721 -0
  9. data/docs/api/prompt.md +858 -0
  10. data/docs/api/resource.md +651 -0
  11. data/docs/api/server.md +509 -0
  12. data/docs/api/test-helpers.md +591 -0
  13. data/docs/api/tool.md +527 -0
  14. data/docs/cookbook/authentication.md +651 -0
  15. data/docs/cookbook/caching.md +877 -0
  16. data/docs/cookbook/dynamic-tools.md +970 -0
  17. data/docs/cookbook/error-handling.md +887 -0
  18. data/docs/cookbook/logging.md +1044 -0
  19. data/docs/cookbook/rate-limiting.md +717 -0
  20. data/docs/examples/code-assistant.md +922 -0
  21. data/docs/examples/complete-server.md +726 -0
  22. data/docs/examples/database-manager.md +1198 -0
  23. data/docs/examples/devops-tools.md +1382 -0
  24. data/docs/examples/echo-server.md +501 -0
  25. data/docs/examples/weather-service.md +822 -0
  26. data/docs/guides/completion.md +472 -0
  27. data/docs/guides/getting-started.md +462 -0
  28. data/docs/guides/middleware.md +823 -0
  29. data/docs/guides/project-structure.md +434 -0
  30. data/docs/guides/prompts.md +920 -0
  31. data/docs/guides/resources.md +720 -0
  32. data/docs/guides/sampling.md +804 -0
  33. data/docs/guides/testing.md +863 -0
  34. data/docs/guides/tools.md +627 -0
  35. data/examples/README.md +92 -0
  36. data/examples/advanced_features.rb +129 -0
  37. data/examples/basic-migrated/app/prompts/weather_chat.rb +44 -0
  38. data/examples/basic-migrated/app/resources/weather_alerts.rb +18 -0
  39. data/examples/basic-migrated/app/tools/get_current_weather.rb +34 -0
  40. data/examples/basic-migrated/app/tools/get_forecast.rb +30 -0
  41. data/examples/basic-migrated/app/tools/get_weather_by_coords.rb +48 -0
  42. data/examples/basic-migrated/server.rb +25 -0
  43. data/examples/basic.rb +73 -0
  44. data/examples/full_featured.rb +175 -0
  45. data/examples/middleware_example.rb +112 -0
  46. data/examples/sampling_example.rb +104 -0
  47. data/examples/weather-service/app/prompts/weather/chat.rb +90 -0
  48. data/examples/weather-service/app/resources/weather/alerts.rb +59 -0
  49. data/examples/weather-service/app/tools/weather/get_current.rb +82 -0
  50. data/examples/weather-service/app/tools/weather/get_forecast.rb +90 -0
  51. data/examples/weather-service/server.rb +28 -0
  52. data/exe/tsikol +6 -0
  53. data/lib/tsikol/cli/templates/Gemfile.erb +10 -0
  54. data/lib/tsikol/cli/templates/README.md.erb +38 -0
  55. data/lib/tsikol/cli/templates/gitignore.erb +49 -0
  56. data/lib/tsikol/cli/templates/prompt.rb.erb +53 -0
  57. data/lib/tsikol/cli/templates/resource.rb.erb +29 -0
  58. data/lib/tsikol/cli/templates/server.rb.erb +24 -0
  59. data/lib/tsikol/cli/templates/tool.rb.erb +60 -0
  60. data/lib/tsikol/cli.rb +203 -0
  61. data/lib/tsikol/error_handler.rb +141 -0
  62. data/lib/tsikol/health.rb +198 -0
  63. data/lib/tsikol/http_transport.rb +72 -0
  64. data/lib/tsikol/lifecycle.rb +149 -0
  65. data/lib/tsikol/middleware.rb +168 -0
  66. data/lib/tsikol/prompt.rb +101 -0
  67. data/lib/tsikol/resource.rb +53 -0
  68. data/lib/tsikol/router.rb +190 -0
  69. data/lib/tsikol/server.rb +660 -0
  70. data/lib/tsikol/stdio_transport.rb +108 -0
  71. data/lib/tsikol/test_helpers.rb +261 -0
  72. data/lib/tsikol/tool.rb +111 -0
  73. data/lib/tsikol/version.rb +5 -0
  74. data/lib/tsikol.rb +72 -0
  75. metadata +219 -0
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= @class_name %> < Tsikol::Tool
4
+ description "TODO: Add description for <%= @tool_name %>"
5
+
6
+ <% @parameters.each do |param| -%>
7
+ parameter :<%= param[:name] %> do
8
+ type :<%= param[:type] %>
9
+ <% if param[:required] -%>
10
+ required
11
+ <% else -%>
12
+ optional
13
+ <% end -%>
14
+ description "TODO: Add description for <%= param[:name] %>"
15
+ <% if param[:type] == 'string' -%>
16
+
17
+ # Add completion for better UX
18
+ # complete do |partial|
19
+ # # Return array of possible values
20
+ # options = ["option1", "option2", "option3"]
21
+ # options.select { |opt| opt.start_with?(partial) }
22
+ # end
23
+ <% elsif param[:type] == 'enum' -%>
24
+
25
+ # For enum types, add the allowed values
26
+ # enum ["option1", "option2", "option3"]
27
+ #
28
+ # And optionally add completion
29
+ # complete do |partial|
30
+ # self.enum_values.select { |val| val.start_with?(partial) }
31
+ # end
32
+ <% end -%>
33
+ end
34
+
35
+ <% end -%>
36
+ def execute(<%= @parameters.map { |p| "#{p[:name]}:#{p[:required] ? '' : ' nil'}" }.join(', ') %>)
37
+ # TODO: Implement <%= @tool_name %> logic
38
+ log :info, "Executing <%= @tool_name %>" if respond_to?(:log)
39
+
40
+ <% if @parameters.any? -%>
41
+ # Access parameters
42
+ <% @parameters.each do |param| -%>
43
+ # <%= param[:name] %> - <%= param[:type] %>
44
+ <% end -%>
45
+
46
+ <% end -%>
47
+ "TODO: Return result"
48
+ end
49
+
50
+ private
51
+
52
+ def set_server(server)
53
+ @server = server
54
+
55
+ # Enable logging
56
+ define_singleton_method(:log) do |level, message, data: nil, logger: nil|
57
+ @server.log(level, message, data: data, logger: logger)
58
+ end
59
+ end
60
+ end
data/lib/tsikol/cli.rb ADDED
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "fileutils"
5
+ require "erb"
6
+
7
+ module Tsikol
8
+ class CLI < Thor
9
+ include Thor::Actions
10
+
11
+ def self.source_root
12
+ File.expand_path("../cli/templates", __FILE__)
13
+ end
14
+
15
+ desc "new PROJECT_NAME", "Create a new Tsikol MCP server project"
16
+ def new(project_name)
17
+ @project_name = project_name
18
+ @class_name = project_name.split(/[-_]/).map(&:capitalize).join
19
+
20
+ say "Creating new Tsikol project: #{project_name}", :green
21
+
22
+ # Create project directory
23
+ empty_directory project_name
24
+
25
+ inside project_name do
26
+ # Create directories
27
+ empty_directory "app/tools"
28
+ empty_directory "app/resources"
29
+ empty_directory "app/prompts"
30
+ empty_directory "config"
31
+ empty_directory "test"
32
+
33
+ # Create files from templates
34
+ template "Gemfile.erb", "Gemfile"
35
+ template "server.rb.erb", "server.rb"
36
+ template "README.md.erb", "README.md"
37
+ template "gitignore.erb", ".gitignore"
38
+
39
+ # Make server.rb executable
40
+ chmod "server.rb", 0755
41
+ end
42
+
43
+ say "\nProject created! Next steps:", :green
44
+ say " cd #{project_name}"
45
+ say " bundle install"
46
+ say " ./server.rb"
47
+ end
48
+
49
+ desc "generate GENERATOR [args]", "Generate new components (aliases: g)"
50
+ map "g" => :generate
51
+
52
+ def generate(generator, name = nil, *args)
53
+ case generator
54
+ when "tool", "t"
55
+ generate_tool(name, *args)
56
+ when "resource", "r"
57
+ generate_resource(name, *args)
58
+ when "prompt", "p"
59
+ generate_prompt(name, *args)
60
+ else
61
+ say "Unknown generator: #{generator}", :red
62
+ say "Available generators: tool (t), resource (r), prompt (p)"
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def generate_tool(name, *params)
69
+ unless name
70
+ say "Usage: tsikol generate tool NAME [param1:type param2:type ...]", :red
71
+ return
72
+ end
73
+
74
+ @tool_name = name
75
+ @class_name = name.split(/[-_]/).map(&:capitalize).join
76
+ @parameters = parse_parameters(params)
77
+
78
+ # Create tool file
79
+ file_path = "app/tools/#{name}.rb"
80
+ template "tool.rb.erb", file_path
81
+
82
+ # Update server.rb
83
+ update_server_file("tool", @class_name)
84
+
85
+ say "Generated tool: #{file_path}", :green
86
+ say "Added to server.rb: tool #{@class_name}", :green
87
+ end
88
+
89
+ def generate_resource(name, *args)
90
+ unless name
91
+ say "Usage: tsikol generate resource NAME", :red
92
+ return
93
+ end
94
+
95
+ @resource_name = name
96
+ @class_name = name.split(/[-_]/).map(&:capitalize).join
97
+ @uri = name.gsub('_', '/')
98
+
99
+ # Create resource file
100
+ file_path = "app/resources/#{name}.rb"
101
+ template "resource.rb.erb", file_path
102
+
103
+ # Update server.rb
104
+ update_server_file("resource", @class_name)
105
+
106
+ say "Generated resource: #{file_path}", :green
107
+ say "Added to server.rb: resource #{@class_name}", :green
108
+ end
109
+
110
+ def generate_prompt(name, *arguments)
111
+ unless name
112
+ say "Usage: tsikol generate prompt NAME [arg1:type arg2:type ...]", :red
113
+ return
114
+ end
115
+
116
+ @prompt_name = name
117
+ @class_name = name.split(/[-_]/).map(&:capitalize).join
118
+ @arguments = parse_parameters(arguments)
119
+
120
+ # Create prompt file
121
+ file_path = "app/prompts/#{name}.rb"
122
+ template "prompt.rb.erb", file_path
123
+
124
+ # Update server.rb
125
+ update_server_file("prompt", @class_name)
126
+
127
+ say "Generated prompt: #{file_path}", :green
128
+ say "Added to server.rb: prompt #{@class_name}", :green
129
+ end
130
+
131
+ def parse_parameters(params)
132
+ params.map do |param|
133
+ name, type = param.split(':')
134
+ type ||= 'string'
135
+ required = !name.include?('?')
136
+ name = name.gsub('?', '')
137
+
138
+ {
139
+ name: name,
140
+ type: type,
141
+ required: required
142
+ }
143
+ end
144
+ end
145
+
146
+ def update_server_file(component_type, class_name)
147
+ server_file = "server.rb"
148
+ return unless File.exist?(server_file)
149
+
150
+ # Read current content
151
+ content = File.read(server_file)
152
+
153
+ # Add require statement if not present
154
+ require_path = "app/#{component_type}s/#{@tool_name || @resource_name || @prompt_name}"
155
+ require_line = "require_relative '#{require_path}'"
156
+
157
+ unless content.include?(require_line)
158
+ # Insert require after other requires or at the beginning
159
+ if content =~ /^require_relative/
160
+ last_require_index = content.rindex(/^require_relative.*$/)
161
+ insert_position = content.index("\n", last_require_index) + 1
162
+ else
163
+ insert_position = content.index(/^Tsikol\.start/) || 0
164
+ end
165
+
166
+ content.insert(insert_position, "#{require_line}\n")
167
+ end
168
+
169
+ # Add component to routes if not present
170
+ component_line = " #{component_type} #{class_name}"
171
+
172
+ unless content.include?(component_line)
173
+ # Find the Tsikol.start block
174
+ if match = content.match(/Tsikol\.start.*?\sdo\s*\n(.*?)\nend/m)
175
+ block_content = match[1]
176
+
177
+ # Find where to insert (after last component of same type or before end)
178
+ lines = block_content.split("\n")
179
+ insert_index = -1
180
+
181
+ lines.each_with_index do |line, i|
182
+ if line.strip.start_with?("#{component_type} ")
183
+ insert_index = i
184
+ end
185
+ end
186
+
187
+ if insert_index >= 0
188
+ lines.insert(insert_index + 1, component_line)
189
+ else
190
+ # No components of this type yet, add at the beginning
191
+ lines.unshift(component_line)
192
+ end
193
+
194
+ new_block = lines.join("\n")
195
+ content.sub!(block_content, new_block)
196
+ end
197
+ end
198
+
199
+ # Write updated content
200
+ File.write(server_file, content)
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ # Enhanced error handling with context and recovery
5
+ class ErrorHandler
6
+ def initialize(server)
7
+ @server = server
8
+ @error_counts = Hash.new(0)
9
+ @circuit_breakers = {}
10
+ end
11
+
12
+ def handle_error(error, context = {})
13
+ error_key = "#{context[:method]}:#{error.class.name}"
14
+ @error_counts[error_key] += 1
15
+
16
+ # Log with context
17
+ @server.log :error, "Error in #{context[:method]}",
18
+ data: {
19
+ error: error.class.name,
20
+ message: error.message,
21
+ context: context,
22
+ count: @error_counts[error_key],
23
+ backtrace: error.backtrace&.first(5)
24
+ }
25
+
26
+ # Check circuit breaker
27
+ if should_circuit_break?(error_key)
28
+ raise CircuitBreakerOpen, "Circuit breaker open for #{context[:method]}"
29
+ end
30
+
31
+ # Determine error response
32
+ case error
33
+ when ValidationError
34
+ [:invalid_params, error.message]
35
+ when AuthenticationError
36
+ [:invalid_request, "Authentication failed: #{error.message}"]
37
+ when RateLimitError
38
+ [:invalid_request, "Rate limit exceeded: #{error.message}"]
39
+ when CircuitBreakerOpen
40
+ [:internal_error, error.message]
41
+ when NotImplementedError
42
+ [:method_not_found, "Method not implemented: #{error.message}"]
43
+ else
44
+ [:internal_error, sanitize_error_message(error)]
45
+ end
46
+ end
47
+
48
+ def wrap_tool_execution(tool_name, &block)
49
+ circuit_breaker = circuit_breaker_for(tool_name)
50
+
51
+ circuit_breaker.call do
52
+ block.call
53
+ end
54
+ rescue => e
55
+ handle_error(e, method: "tools/call", tool: tool_name)
56
+ raise
57
+ end
58
+
59
+ private
60
+
61
+ def should_circuit_break?(error_key)
62
+ # Simple circuit breaker: open after 5 errors in 60 seconds
63
+ recent_errors = @error_counts[error_key]
64
+ recent_errors >= 5
65
+ end
66
+
67
+ def circuit_breaker_for(name)
68
+ @circuit_breakers[name] ||= CircuitBreaker.new(
69
+ name: name,
70
+ threshold: 5,
71
+ timeout: 60,
72
+ on_open: -> { @server.log :warning, "Circuit breaker opened for #{name}" }
73
+ )
74
+ end
75
+
76
+ def sanitize_error_message(error)
77
+ # Don't expose internal details in production
78
+ if ENV['TSIKOL_ENV'] == 'production'
79
+ "Internal server error"
80
+ else
81
+ "#{error.class.name}: #{error.message}"
82
+ end
83
+ end
84
+ end
85
+
86
+ # Simple circuit breaker implementation
87
+ class CircuitBreaker
88
+ def initialize(name:, threshold: 5, timeout: 60, on_open: nil)
89
+ @name = name
90
+ @threshold = threshold
91
+ @timeout = timeout
92
+ @on_open = on_open
93
+ @failures = 0
94
+ @last_failure_time = nil
95
+ @state = :closed
96
+ end
97
+
98
+ def call(&block)
99
+ case @state
100
+ when :open
101
+ if Time.now - @last_failure_time > @timeout
102
+ @state = :half_open
103
+ else
104
+ raise CircuitBreakerOpen, "Circuit breaker is open for #{@name}"
105
+ end
106
+ end
107
+
108
+ begin
109
+ result = block.call
110
+ # Success - reset on half-open
111
+ if @state == :half_open
112
+ @failures = 0
113
+ @state = :closed
114
+ end
115
+ result
116
+ rescue => e
117
+ record_failure
118
+ raise e
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def record_failure
125
+ @failures += 1
126
+ @last_failure_time = Time.now
127
+
128
+ if @failures >= @threshold
129
+ @state = :open
130
+ @on_open&.call
131
+ end
132
+ end
133
+ end
134
+
135
+ # Custom error types
136
+ class TsikolError < StandardError; end
137
+ class ValidationError < TsikolError; end
138
+ class AuthenticationError < TsikolError; end
139
+ class RateLimitError < TsikolError; end
140
+ class CircuitBreakerOpen < TsikolError; end
141
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ # Health monitoring and metrics
5
+ module Health
6
+ def self.included(base)
7
+ base.class_eval do
8
+ attr_reader :metrics
9
+ end
10
+ end
11
+
12
+ def initialize_health_monitoring
13
+ @metrics = Metrics.new
14
+ @start_time = Time.now
15
+
16
+ # Add built-in health check resource
17
+ resource "health" do
18
+ health_status.to_json
19
+ end
20
+
21
+ # Add detailed health check
22
+ resource "health/detailed" do
23
+ detailed_health_status.to_json
24
+ end
25
+
26
+ # Add metrics endpoint
27
+ resource "metrics" do
28
+ @metrics.to_h.to_json
29
+ end
30
+ end
31
+
32
+ def health_status
33
+ {
34
+ status: health_check_status,
35
+ timestamp: Time.now.iso8601,
36
+ uptime: Time.now - @start_time,
37
+ version: @version
38
+ }
39
+ end
40
+
41
+ def detailed_health_status
42
+ health_status.merge(
43
+ checks: {
44
+ tools: check_tools_health,
45
+ resources: check_resources_health,
46
+ memory: check_memory_health,
47
+ error_rate: check_error_rate
48
+ },
49
+ metrics: {
50
+ requests_total: @metrics.get(:requests_total),
51
+ errors_total: @metrics.get(:errors_total),
52
+ active_requests: @metrics.get(:active_requests),
53
+ average_response_time: @metrics.average(:response_time)
54
+ }
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def health_check_status
61
+ # Simple health logic
62
+ error_rate = calculate_error_rate
63
+
64
+ if error_rate > 0.5
65
+ "unhealthy"
66
+ elsif error_rate > 0.1
67
+ "degraded"
68
+ else
69
+ "healthy"
70
+ end
71
+ end
72
+
73
+ def check_tools_health
74
+ total = @tools.size + @tool_instances.size
75
+ {
76
+ total: total,
77
+ status: total > 0 ? "ok" : "no_tools"
78
+ }
79
+ end
80
+
81
+ def check_resources_health
82
+ total = @resources.size + @resource_instances.size
83
+ {
84
+ total: total,
85
+ status: total > 0 ? "ok" : "no_resources"
86
+ }
87
+ end
88
+
89
+ def check_memory_health
90
+ # Simple memory check (would be more sophisticated in production)
91
+ {
92
+ usage: "#{(GC.stat[:heap_live_slots] * 40.0 / 1024 / 1024).round(2)}MB",
93
+ status: "ok"
94
+ }
95
+ end
96
+
97
+ def check_error_rate
98
+ error_rate = calculate_error_rate
99
+ {
100
+ rate: error_rate.round(3),
101
+ status: error_rate < 0.05 ? "ok" : "high"
102
+ }
103
+ end
104
+
105
+ def calculate_error_rate
106
+ total = @metrics.get(:requests_total)
107
+ errors = @metrics.get(:errors_total)
108
+
109
+ return 0.0 if total == 0
110
+ errors.to_f / total
111
+ end
112
+ end
113
+
114
+ # Simple metrics collector
115
+ class Metrics
116
+ def initialize
117
+ @counters = Hash.new(0)
118
+ @gauges = {}
119
+ @histograms = Hash.new { |h, k| h[k] = [] }
120
+ @mutex = Mutex.new
121
+ end
122
+
123
+ def increment(name, value = 1)
124
+ @mutex.synchronize do
125
+ @counters[name] += value
126
+ end
127
+ end
128
+
129
+ def decrement(name, value = 1)
130
+ increment(name, -value)
131
+ end
132
+
133
+ def set(name, value)
134
+ @mutex.synchronize do
135
+ @gauges[name] = value
136
+ end
137
+ end
138
+
139
+ def record(name, value)
140
+ @mutex.synchronize do
141
+ @histograms[name] << value
142
+ # Keep only last 1000 values
143
+ @histograms[name] = @histograms[name].last(1000)
144
+ end
145
+ end
146
+
147
+ def get(name)
148
+ @mutex.synchronize do
149
+ @counters[name] || @gauges[name] || 0
150
+ end
151
+ end
152
+
153
+ def average(name)
154
+ @mutex.synchronize do
155
+ values = @histograms[name]
156
+ return 0 if values.empty?
157
+ values.sum.to_f / values.size
158
+ end
159
+ end
160
+
161
+ def percentile(name, p)
162
+ @mutex.synchronize do
163
+ values = @histograms[name].sort
164
+ return 0 if values.empty?
165
+
166
+ index = (values.size * p / 100.0).ceil - 1
167
+ values[index]
168
+ end
169
+ end
170
+
171
+ def to_h
172
+ @mutex.synchronize do
173
+ {
174
+ counters: @counters.dup,
175
+ gauges: @gauges.dup,
176
+ histograms: @histograms.transform_values do |values|
177
+ {
178
+ count: values.size,
179
+ average: values.empty? ? 0 : values.sum.to_f / values.size,
180
+ p50: percentile_for_values(values, 50),
181
+ p95: percentile_for_values(values, 95),
182
+ p99: percentile_for_values(values, 99)
183
+ }
184
+ end
185
+ }
186
+ end
187
+ end
188
+
189
+ private
190
+
191
+ def percentile_for_values(values, p)
192
+ return 0 if values.empty?
193
+ sorted = values.sort
194
+ index = (sorted.size * p / 100.0).ceil - 1
195
+ sorted[index]
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,72 @@
1
+ require 'sinatra/base'
2
+ require 'json'
3
+
4
+ module Tsikol
5
+ class HttpTransport < Sinatra::Base
6
+ set :logging, false
7
+ set :quiet, true
8
+
9
+ def initialize(server)
10
+ super()
11
+ @server = server
12
+ @sessions = {}
13
+ end
14
+
15
+ post '/mcp' do
16
+ headers['Access-Control-Allow-Origin'] = '*'
17
+ headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
18
+ headers['Access-Control-Allow-Headers'] = 'Content-Type, Accept'
19
+
20
+ request_body = request.body.read
21
+
22
+ response = @server.handle_message(request_body)
23
+
24
+ content_type :json
25
+ response.to_json
26
+ end
27
+
28
+ get '/mcp' do
29
+ headers['Content-Type'] = 'text/event-stream'
30
+ headers['Cache-Control'] = 'no-cache'
31
+ headers['Connection'] = 'keep-alive'
32
+ headers['Access-Control-Allow-Origin'] = '*'
33
+
34
+ session_id = params['sessionId'] || SecureRandom.uuid
35
+
36
+ stream do |out|
37
+ # Send initial connection event
38
+ out << "event: open\n"
39
+ out << "data: {\"sessionId\":\"#{session_id}\"}\n\n"
40
+
41
+ # Store the session
42
+ @sessions[session_id] = { stream: out, queue: Queue.new }
43
+
44
+ # Keep connection alive
45
+ EventMachine.add_periodic_timer(30) do
46
+ out << ":ping\n\n" rescue nil
47
+ end
48
+
49
+ # Wait for messages to send
50
+ loop do
51
+ begin
52
+ message = @sessions[session_id][:queue].pop
53
+ out << "event: message\n"
54
+ out << "data: #{message.to_json}\n\n"
55
+ rescue => e
56
+ break
57
+ end
58
+ end
59
+
60
+ # Clean up on disconnect
61
+ @sessions.delete(session_id)
62
+ end
63
+ end
64
+
65
+ options '/mcp' do
66
+ headers['Access-Control-Allow-Origin'] = '*'
67
+ headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
68
+ headers['Access-Control-Allow-Headers'] = 'Content-Type, Accept'
69
+ 200
70
+ end
71
+ end
72
+ end