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,108 @@
1
+ module Tsikol
2
+ class StdioTransport
3
+ def initialize(server)
4
+ @server = server
5
+ @server.set_transport(self)
6
+ end
7
+
8
+ def send_notification(notification)
9
+ notification_json = notification.to_json
10
+ $stdout.puts(notification_json)
11
+ $stdout.flush
12
+ end
13
+
14
+ def start
15
+ # Don't log to stderr during startup - MCP Inspector treats any stderr output as an error
16
+
17
+ # Run lifecycle hooks
18
+ @server.run_before_start_hooks if @server.respond_to?(:run_before_start_hooks)
19
+ @server.run_after_start_hooks if @server.respond_to?(:run_after_start_hooks)
20
+
21
+ # Set up signal handlers
22
+ setup_signal_handlers
23
+
24
+ loop do
25
+ # Read a line from stdin
26
+ line = $stdin.gets
27
+ break unless line
28
+
29
+ line = line.strip
30
+ next if line.empty?
31
+
32
+ # Check if this is a Content-Length header or raw JSON
33
+ if line.start_with?('Content-Length:')
34
+ # Handle Content-Length format
35
+ headers = { 'content-length' => line.split(':', 2)[1].strip }
36
+
37
+ # Read remaining headers
38
+ while header_line = $stdin.gets
39
+ header_line = header_line.strip
40
+ break if header_line.empty?
41
+
42
+ key, value = header_line.split(': ', 2)
43
+ headers[key.downcase] = value if key && value
44
+ end
45
+
46
+ # Read body based on content length
47
+ content_length = headers['content-length'].to_i
48
+ body = $stdin.read(content_length)
49
+ else
50
+ # Handle raw JSON format (what MCP Inspector sends)
51
+ body = line
52
+ end
53
+
54
+ begin
55
+ # Log incoming message for debugging
56
+ File.open('/tmp/tsikol-debug.log', 'a') do |f|
57
+ f.puts "#{Time.now}: Received message: #{body}"
58
+ end
59
+
60
+ # Process message and get response
61
+ response = @server.handle_message(body)
62
+
63
+ # Only send response if not nil
64
+ if response
65
+ # Write response as plain JSON line (what MCP Inspector expects)
66
+ response_json = response.to_json
67
+ $stdout.puts(response_json)
68
+ $stdout.flush
69
+
70
+ # Log response for debugging
71
+ File.open('/tmp/tsikol-debug.log', 'a') do |f|
72
+ f.puts "#{Time.now}: Sent response: #{response_json}"
73
+ end
74
+ end
75
+ rescue => e
76
+ # Log errors to stderr
77
+ $stderr.puts "Error: #{e.message}"
78
+ $stderr.puts e.backtrace.join("\n")
79
+ end
80
+ end
81
+ rescue EOFError
82
+ $stderr.puts "Connection closed"
83
+ rescue Interrupt
84
+ $stderr.puts "Server stopped"
85
+ ensure
86
+ shutdown
87
+ end
88
+
89
+ private
90
+
91
+ def setup_signal_handlers
92
+ %w[INT TERM].each do |signal|
93
+ Signal.trap(signal) do
94
+ $stderr.puts "Received #{signal} signal, shutting down..."
95
+ shutdown
96
+ exit(0)
97
+ end
98
+ end
99
+ end
100
+
101
+ def shutdown
102
+ return unless @server.respond_to?(:run_before_stop_hooks)
103
+
104
+ @server.run_before_stop_hooks
105
+ @server.run_after_stop_hooks
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Tsikol
6
+ module TestHelpers
7
+ # Mock MCP client for testing servers
8
+ class TestClient
9
+ attr_reader :server, :responses, :notifications
10
+
11
+ def initialize(server)
12
+ @server = server
13
+ @responses = []
14
+ @notifications = []
15
+ @request_id = 0
16
+
17
+ # Capture notifications
18
+ if server.respond_to?(:set_transport)
19
+ server.set_transport(self)
20
+ end
21
+ end
22
+
23
+ # Send notification (called by server)
24
+ def send_notification(notification)
25
+ @notifications << notification
26
+ end
27
+
28
+ # Initialize connection
29
+ def initialize_connection(client_info = {})
30
+ request("initialize", {
31
+ protocolVersion: Tsikol::PROTOCOL_VERSION,
32
+ capabilities: client_info[:capabilities] || {},
33
+ clientInfo: {
34
+ name: client_info[:name] || "test-client",
35
+ version: client_info[:version] || "1.0.0"
36
+ }
37
+ })
38
+ end
39
+
40
+ # Make a request to the server
41
+ def request(method, params = nil)
42
+ message = {
43
+ "jsonrpc" => "2.0",
44
+ "id" => next_id,
45
+ "method" => method
46
+ }
47
+ message["params"] = params if params
48
+
49
+ response = @server.handle_message(message.to_json)
50
+ @responses << response
51
+ response
52
+ end
53
+
54
+ # Call a tool
55
+ def call_tool(name, arguments = {})
56
+ request("tools/call", {
57
+ "name" => name,
58
+ "arguments" => arguments
59
+ })
60
+ end
61
+
62
+ # Read a resource
63
+ def read_resource(uri)
64
+ request("resources/read", { "uri" => uri })
65
+ end
66
+
67
+ # Get a prompt
68
+ def get_prompt(name, arguments = {})
69
+ request("prompts/get", {
70
+ "name" => name,
71
+ "arguments" => arguments
72
+ })
73
+ end
74
+
75
+ # List available tools
76
+ def list_tools
77
+ request("tools/list")
78
+ end
79
+
80
+ # List available resources
81
+ def list_resources
82
+ request("resources/list")
83
+ end
84
+
85
+ # List available prompts
86
+ def list_prompts
87
+ request("prompts/list")
88
+ end
89
+
90
+ # Get completion suggestions
91
+ def complete(ref, argument = nil)
92
+ params = { "ref" => ref }
93
+ params["argument"] = argument if argument
94
+ request("completion/complete", params)
95
+ end
96
+
97
+ # Request sampling
98
+ def sample(messages, options = {})
99
+ params = {
100
+ "messages" => messages,
101
+ "modelPreferences" => options[:model_preferences] || {},
102
+ "systemPrompt" => options[:system_prompt],
103
+ "maxTokens" => options[:max_tokens]
104
+ }
105
+ request("sampling/createMessage", params)
106
+ end
107
+
108
+ # Set logging level
109
+ def set_log_level(level)
110
+ request("logging/setLevel", { "level" => level })
111
+ end
112
+
113
+ # Send ping
114
+ def ping
115
+ request("ping")
116
+ end
117
+
118
+ # Get last response
119
+ def last_response
120
+ @responses.last
121
+ end
122
+
123
+ # Get last notification
124
+ def last_notification
125
+ @notifications.last
126
+ end
127
+
128
+ # Clear captured data
129
+ def clear!
130
+ @responses.clear
131
+ @notifications.clear
132
+ end
133
+
134
+ private
135
+
136
+ def next_id
137
+ @request_id += 1
138
+ end
139
+ end
140
+
141
+ # RSpec matchers
142
+ module Matchers
143
+ # Check if response was successful
144
+ class BeSuccessful
145
+ def matches?(response)
146
+ response && !response[:error]
147
+ end
148
+
149
+ def failure_message
150
+ "expected response to be successful, but got error: #{@response[:error]}"
151
+ end
152
+ end
153
+
154
+ # Check if response has error
155
+ class HaveError
156
+ def initialize(code = nil, message = nil)
157
+ @expected_code = code
158
+ @expected_message = message
159
+ end
160
+
161
+ def matches?(response)
162
+ return false unless response && response[:error]
163
+
164
+ if @expected_code
165
+ return false unless response[:error][:code] == @expected_code
166
+ end
167
+
168
+ if @expected_message
169
+ return false unless response[:error][:message].include?(@expected_message)
170
+ end
171
+
172
+ true
173
+ end
174
+
175
+ def failure_message
176
+ "expected response to have error #{@expected_code} with message containing '#{@expected_message}'"
177
+ end
178
+ end
179
+
180
+ # Check if server has capability
181
+ class HaveCapability
182
+ def initialize(capability)
183
+ @capability = capability
184
+ end
185
+
186
+ def matches?(server_or_response)
187
+ if server_or_response.is_a?(Hash)
188
+ # Response from initialize
189
+ capabilities = server_or_response.dig(:result, :capabilities) || {}
190
+ else
191
+ # Server instance
192
+ capabilities = server_or_response.instance_variable_get(:@server_capabilities) || {}
193
+ end
194
+
195
+ capabilities.key?(@capability.to_sym) || capabilities.key?(@capability.to_s)
196
+ end
197
+
198
+ def failure_message
199
+ "expected server to have capability :#{@capability}"
200
+ end
201
+ end
202
+
203
+ def be_successful
204
+ BeSuccessful.new
205
+ end
206
+
207
+ def have_error(code = nil, message = nil)
208
+ HaveError.new(code, message)
209
+ end
210
+
211
+ def have_capability(capability)
212
+ HaveCapability.new(capability)
213
+ end
214
+ end
215
+
216
+ # Test assertions
217
+ module Assertions
218
+ def assert_successful_response(response)
219
+ assert response && !response[:error],
220
+ "Expected successful response, got: #{response.inspect}"
221
+ end
222
+
223
+ def assert_error_response(response, code = nil, message_pattern = nil)
224
+ assert response && response[:error],
225
+ "Expected error response, got: #{response.inspect}"
226
+
227
+ if code
228
+ assert_equal code, response[:error][:code],
229
+ "Expected error code #{code}, got: #{response[:error][:code]}"
230
+ end
231
+
232
+ if message_pattern
233
+ assert_match message_pattern, response[:error][:message],
234
+ "Expected error message to match #{message_pattern}"
235
+ end
236
+ end
237
+
238
+ def assert_has_capability(server, capability)
239
+ capabilities = server.instance_variable_get(:@server_capabilities) || {}
240
+ assert capabilities.key?(capability.to_sym) || capabilities.key?(capability.to_s),
241
+ "Expected server to have capability :#{capability}"
242
+ end
243
+
244
+ def assert_tool_result(client, tool_name, arguments, expected)
245
+ response = client.call_tool(tool_name, arguments)
246
+ assert_successful_response(response)
247
+
248
+ result = response.dig(:result, :content, 0, :text)
249
+ assert_equal expected, result
250
+ end
251
+
252
+ def assert_resource_content(client, uri, expected)
253
+ response = client.read_resource(uri)
254
+ assert_successful_response(response)
255
+
256
+ content = response.dig(:result, :contents, 0, :text)
257
+ assert_equal expected, content
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ class Tool
5
+ class << self
6
+ attr_reader :tool_description, :parameters_config
7
+
8
+ def description(desc)
9
+ @tool_description = desc
10
+ end
11
+
12
+ def parameter(name, &block)
13
+ @parameters_config ||= {}
14
+ param = ParameterBuilder.new(name)
15
+ param.instance_eval(&block) if block_given?
16
+ @parameters_config[name] = param.build
17
+ end
18
+
19
+ def tool_name
20
+ # Convert class name to tool name
21
+ # Weather::GetCurrent -> weather:get_current
22
+ name.gsub('::', ':').gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
23
+ end
24
+ end
25
+
26
+ def description
27
+ self.class.tool_description || "Tool: #{self.class.tool_name}"
28
+ end
29
+
30
+ def parameters
31
+ self.class.parameters_config || {}
32
+ end
33
+
34
+ def execute(**args)
35
+ raise NotImplementedError, "Subclasses must implement execute method"
36
+ end
37
+
38
+ # Convert to MCP format
39
+ def to_mcp
40
+ {
41
+ name: self.class.tool_name,
42
+ description: description,
43
+ inputSchema: build_input_schema
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def build_input_schema
50
+ properties = {}
51
+ required = []
52
+
53
+ parameters.each do |name, config|
54
+ properties[name.to_s] = {
55
+ type: config[:type].to_s,
56
+ description: config[:description]
57
+ }
58
+
59
+ properties[name.to_s][:enum] = config[:enum] if config[:enum]
60
+ properties[name.to_s][:default] = config[:default] if config.key?(:default)
61
+
62
+ required << name.to_s if config[:required]
63
+ end
64
+
65
+ {
66
+ type: "object",
67
+ properties: properties,
68
+ required: required
69
+ }
70
+ end
71
+ end
72
+
73
+ class ParameterBuilder
74
+ def initialize(name)
75
+ @name = name
76
+ @config = { name: name }
77
+ end
78
+
79
+ def type(t)
80
+ @config[:type] = t
81
+ end
82
+
83
+ def required(val = true)
84
+ @config[:required] = val
85
+ end
86
+
87
+ def optional
88
+ @config[:required] = false
89
+ end
90
+
91
+ def description(desc)
92
+ @config[:description] = desc
93
+ end
94
+
95
+ def default(val)
96
+ @config[:default] = val
97
+ end
98
+
99
+ def enum(values)
100
+ @config[:enum] = values
101
+ end
102
+
103
+ def complete(&block)
104
+ @config[:completion] = block
105
+ end
106
+
107
+ def build
108
+ @config
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tsikol
4
+ VERSION = "0.1.0"
5
+ end
data/lib/tsikol.rb ADDED
@@ -0,0 +1,72 @@
1
+ require 'json'
2
+ require_relative 'tsikol/version'
3
+ require_relative 'tsikol/server'
4
+ require_relative 'tsikol/stdio_transport'
5
+ require_relative 'tsikol/http_transport'
6
+ require_relative 'tsikol/tool'
7
+ require_relative 'tsikol/resource'
8
+ require_relative 'tsikol/prompt'
9
+ require_relative 'tsikol/router'
10
+ require_relative 'tsikol/middleware'
11
+ require_relative 'tsikol/error_handler'
12
+ require_relative 'tsikol/lifecycle'
13
+ require_relative 'tsikol/health'
14
+ require_relative 'tsikol/cli' if defined?(Thor)
15
+
16
+ module Tsikol
17
+
18
+ PROTOCOL_VERSION = "2025-06-18"
19
+
20
+ module Errors
21
+ PARSE_ERROR = -32700
22
+ INVALID_REQUEST = -32600
23
+ METHOD_NOT_FOUND = -32601
24
+ INVALID_PARAMS = -32602
25
+ INTERNAL_ERROR = -32603
26
+ end
27
+
28
+ # For stdio transport (MCP Inspector, CLI tools)
29
+ def self.server(name, version: VERSION, &block)
30
+ server = Server.new(name: name, version: version)
31
+ server.instance_eval(&block) if block_given?
32
+
33
+ transport = StdioTransport.new(server)
34
+ transport.start
35
+ end
36
+
37
+ # For HTTP transport (web services)
38
+ def self.http_server(name, version: VERSION, port: 4567, &block)
39
+ server = Server.new(name: name, version: version)
40
+ server.instance_eval(&block) if block_given?
41
+
42
+ app = HttpTransport.new(server)
43
+ app.run! do |server|
44
+ server.port = port
45
+ end
46
+ end
47
+
48
+ # New structure with routes file or inline routes
49
+ def self.start(name: nil, version: VERSION, transport: :stdio, &block)
50
+ # Determine server name
51
+ server_name = name || "tsikol-server"
52
+
53
+ # Create server
54
+ server = Server.new(name: server_name, version: version)
55
+
56
+ # Check if block contains route definitions
57
+ if block_given?
58
+ # Create a router and evaluate the block
59
+ router = Router.new(server)
60
+ router.instance_eval(&block)
61
+ end
62
+
63
+ # Start transport
64
+ case transport
65
+ when :stdio
66
+ transport = StdioTransport.new(server)
67
+ transport.start
68
+ when :http
69
+ # TODO: HTTP transport
70
+ end
71
+ end
72
+ end