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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +22 -0
- data/CONTRIBUTING.md +84 -0
- data/LICENSE +21 -0
- data/README.md +579 -0
- data/Rakefile +12 -0
- data/docs/README.md +69 -0
- data/docs/api/middleware.md +721 -0
- data/docs/api/prompt.md +858 -0
- data/docs/api/resource.md +651 -0
- data/docs/api/server.md +509 -0
- data/docs/api/test-helpers.md +591 -0
- data/docs/api/tool.md +527 -0
- data/docs/cookbook/authentication.md +651 -0
- data/docs/cookbook/caching.md +877 -0
- data/docs/cookbook/dynamic-tools.md +970 -0
- data/docs/cookbook/error-handling.md +887 -0
- data/docs/cookbook/logging.md +1044 -0
- data/docs/cookbook/rate-limiting.md +717 -0
- data/docs/examples/code-assistant.md +922 -0
- data/docs/examples/complete-server.md +726 -0
- data/docs/examples/database-manager.md +1198 -0
- data/docs/examples/devops-tools.md +1382 -0
- data/docs/examples/echo-server.md +501 -0
- data/docs/examples/weather-service.md +822 -0
- data/docs/guides/completion.md +472 -0
- data/docs/guides/getting-started.md +462 -0
- data/docs/guides/middleware.md +823 -0
- data/docs/guides/project-structure.md +434 -0
- data/docs/guides/prompts.md +920 -0
- data/docs/guides/resources.md +720 -0
- data/docs/guides/sampling.md +804 -0
- data/docs/guides/testing.md +863 -0
- data/docs/guides/tools.md +627 -0
- data/examples/README.md +92 -0
- data/examples/advanced_features.rb +129 -0
- data/examples/basic-migrated/app/prompts/weather_chat.rb +44 -0
- data/examples/basic-migrated/app/resources/weather_alerts.rb +18 -0
- data/examples/basic-migrated/app/tools/get_current_weather.rb +34 -0
- data/examples/basic-migrated/app/tools/get_forecast.rb +30 -0
- data/examples/basic-migrated/app/tools/get_weather_by_coords.rb +48 -0
- data/examples/basic-migrated/server.rb +25 -0
- data/examples/basic.rb +73 -0
- data/examples/full_featured.rb +175 -0
- data/examples/middleware_example.rb +112 -0
- data/examples/sampling_example.rb +104 -0
- data/examples/weather-service/app/prompts/weather/chat.rb +90 -0
- data/examples/weather-service/app/resources/weather/alerts.rb +59 -0
- data/examples/weather-service/app/tools/weather/get_current.rb +82 -0
- data/examples/weather-service/app/tools/weather/get_forecast.rb +90 -0
- data/examples/weather-service/server.rb +28 -0
- data/exe/tsikol +6 -0
- data/lib/tsikol/cli/templates/Gemfile.erb +10 -0
- data/lib/tsikol/cli/templates/README.md.erb +38 -0
- data/lib/tsikol/cli/templates/gitignore.erb +49 -0
- data/lib/tsikol/cli/templates/prompt.rb.erb +53 -0
- data/lib/tsikol/cli/templates/resource.rb.erb +29 -0
- data/lib/tsikol/cli/templates/server.rb.erb +24 -0
- data/lib/tsikol/cli/templates/tool.rb.erb +60 -0
- data/lib/tsikol/cli.rb +203 -0
- data/lib/tsikol/error_handler.rb +141 -0
- data/lib/tsikol/health.rb +198 -0
- data/lib/tsikol/http_transport.rb +72 -0
- data/lib/tsikol/lifecycle.rb +149 -0
- data/lib/tsikol/middleware.rb +168 -0
- data/lib/tsikol/prompt.rb +101 -0
- data/lib/tsikol/resource.rb +53 -0
- data/lib/tsikol/router.rb +190 -0
- data/lib/tsikol/server.rb +660 -0
- data/lib/tsikol/stdio_transport.rb +108 -0
- data/lib/tsikol/test_helpers.rb +261 -0
- data/lib/tsikol/tool.rb +111 -0
- data/lib/tsikol/version.rb +5 -0
- data/lib/tsikol.rb +72 -0
- 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
|