rubyn-code 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 (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ class Streaming
6
+ ParseError = Class.new(RubynCode::Error)
7
+ OverloadError = Class.new(RubynCode::Error)
8
+
9
+ Event = Data.define(:type, :data)
10
+
11
+ def initialize(&block)
12
+ @callback = block
13
+ @buffer = +""
14
+ @response_id = nil
15
+ @content_blocks = []
16
+ @current_block_index = nil
17
+ @current_text = +""
18
+ @current_tool_input_json = +""
19
+ @stop_reason = nil
20
+ @usage = nil
21
+ end
22
+
23
+ # Feed raw SSE data chunk from the HTTP response body.
24
+ def feed(chunk)
25
+ @buffer << chunk
26
+ consume_events
27
+ end
28
+
29
+ # Returns the fully assembled Response once the stream completes.
30
+ def finalize
31
+ Response.new(
32
+ id: @response_id,
33
+ content: build_content_blocks,
34
+ stop_reason: @stop_reason,
35
+ usage: @usage
36
+ )
37
+ end
38
+
39
+ private
40
+
41
+ def consume_events
42
+ while (idx = @buffer.index("\n\n"))
43
+ raw_event = @buffer.slice!(0..idx + 1)
44
+ parse_sse(raw_event)
45
+ end
46
+ end
47
+
48
+ def parse_sse(raw)
49
+ event_type = nil
50
+ data_lines = []
51
+
52
+ raw.each_line do |line|
53
+ line = line.chomp
54
+ case line
55
+ when /\Aevent:\s*(.+)/
56
+ event_type = ::Regexp.last_match(1).strip
57
+ when /\Adata:\s*(.*)/
58
+ data_lines << ::Regexp.last_match(1)
59
+ end
60
+ end
61
+
62
+ return if data_lines.empty? && event_type.nil?
63
+
64
+ data_str = data_lines.join("\n")
65
+ data = data_str.empty? ? {} : parse_json(data_str)
66
+
67
+ dispatch(event_type, data)
68
+ end
69
+
70
+ def dispatch(event_type, data)
71
+ case event_type
72
+ when "message_start"
73
+ handle_message_start(data)
74
+ when "content_block_start"
75
+ handle_content_block_start(data)
76
+ when "content_block_delta"
77
+ handle_content_block_delta(data)
78
+ when "content_block_stop"
79
+ handle_content_block_stop(data)
80
+ when "message_delta"
81
+ handle_message_delta(data)
82
+ when "message_stop"
83
+ handle_message_stop
84
+ when "ping"
85
+ # ignore
86
+ when "error"
87
+ handle_error(data)
88
+ end
89
+ end
90
+
91
+ def handle_message_start(data)
92
+ message = data.dig("message") || data
93
+ @response_id = message["id"]
94
+
95
+ if (u = message["usage"])
96
+ @usage = Usage.new(input_tokens: u["input_tokens"].to_i, output_tokens: u["output_tokens"].to_i)
97
+ end
98
+
99
+ emit(:message_start, data)
100
+ end
101
+
102
+ def handle_content_block_start(data)
103
+ @current_block_index = data["index"]
104
+ block = data["content_block"] || {}
105
+
106
+ case block["type"]
107
+ when "text"
108
+ @current_text = +(block["text"] || "")
109
+ when "tool_use"
110
+ @current_tool_id = block["id"]
111
+ @current_tool_name = block["name"]
112
+ @current_tool_input_json = +""
113
+ end
114
+
115
+ emit(:content_block_start, data)
116
+ end
117
+
118
+ def handle_content_block_delta(data)
119
+ delta = data["delta"] || {}
120
+
121
+ case delta["type"]
122
+ when "text_delta"
123
+ text = delta["text"] || ""
124
+ @current_text << text
125
+ emit(:text_delta, { index: data["index"], text: text })
126
+ when "input_json_delta"
127
+ json_chunk = delta["partial_json"] || ""
128
+ @current_tool_input_json << json_chunk
129
+ emit(:input_json_delta, { index: data["index"], partial_json: json_chunk })
130
+ end
131
+
132
+ emit(:content_block_delta, data)
133
+ end
134
+
135
+ def handle_content_block_stop(data)
136
+ index = data["index"].to_i
137
+
138
+ if @current_tool_id
139
+ input = parse_json(@current_tool_input_json)
140
+ @content_blocks[index] = ToolUseBlock.new(
141
+ id: @current_tool_id,
142
+ name: @current_tool_name,
143
+ input: input || {}
144
+ )
145
+ @current_tool_id = nil
146
+ @current_tool_name = nil
147
+ @current_tool_input_json = +""
148
+ else
149
+ @content_blocks[index] = TextBlock.new(text: @current_text.dup)
150
+ @current_text = +""
151
+ end
152
+
153
+ emit(:content_block_stop, data)
154
+ end
155
+
156
+ def handle_message_delta(data)
157
+ delta = data["delta"] || {}
158
+ @stop_reason = delta["stop_reason"] if delta["stop_reason"]
159
+
160
+ if (u = data["usage"])
161
+ @usage = Usage.new(
162
+ input_tokens: (@usage&.input_tokens || 0),
163
+ output_tokens: u["output_tokens"].to_i
164
+ )
165
+ end
166
+
167
+ emit(:message_delta, data)
168
+ end
169
+
170
+ def handle_message_stop
171
+ emit(:message_stop, {})
172
+ end
173
+
174
+ def handle_error(data)
175
+ error = data.dig("error") || data
176
+ error_type = error["type"] || "unknown"
177
+ message = error["message"] || "Unknown streaming error"
178
+
179
+ if error_type == "overloaded_error"
180
+ raise OverloadError, message
181
+ end
182
+
183
+ raise ParseError, "Streaming error (#{error_type}): #{message}"
184
+ end
185
+
186
+ def emit(type, data)
187
+ @callback&.call(Event.new(type: type, data: data))
188
+ end
189
+
190
+ def build_content_blocks
191
+ @content_blocks.compact
192
+ end
193
+
194
+ def parse_json(str)
195
+ return nil if str.nil? || str.strip.empty?
196
+
197
+ JSON.parse(str)
198
+ rescue JSON::ParserError
199
+ nil
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubynCode
6
+ module MCP
7
+ # High-level MCP client that manages the connection lifecycle,
8
+ # tool discovery, and tool invocation for a single MCP server.
9
+ class Client
10
+ INITIALIZE_TIMEOUT = 10
11
+
12
+ ClientError = Class.new(RubynCode::Error)
13
+
14
+ attr_reader :name, :transport
15
+
16
+ # @param name [String] human-readable name for this MCP server connection
17
+ # @param transport [StdioTransport, SSETransport] the underlying transport
18
+ def initialize(name:, transport:)
19
+ @name = name
20
+ @transport = transport
21
+ @tools_cache = nil
22
+ @initialized = false
23
+ end
24
+
25
+ # Starts the transport, performs the MCP initialize handshake,
26
+ # and discovers available tools.
27
+ #
28
+ # @return [void]
29
+ # @raise [ClientError] if initialization fails
30
+ def connect!
31
+ @transport.start!
32
+ perform_initialize
33
+ @initialized = true
34
+ rescue StandardError => e
35
+ @transport.stop!
36
+ raise ClientError, "Failed to connect to MCP server '#{@name}': #{e.message}"
37
+ end
38
+
39
+ # Returns the list of tool definitions from the MCP server.
40
+ # Each tool is a Hash with "name", "description", and "inputSchema" keys.
41
+ #
42
+ # @return [Array<Hash>] tool definitions in JSON Schema format
43
+ def tools
44
+ @tools_cache ||= discover_tools
45
+ end
46
+
47
+ # Invokes a tool on the MCP server.
48
+ #
49
+ # @param tool_name [String] the name of the tool to call
50
+ # @param arguments [Hash] the arguments to pass to the tool
51
+ # @return [Hash] the tool's result
52
+ # @raise [ClientError] if the client is not connected
53
+ def call_tool(tool_name, arguments = {})
54
+ ensure_connected!
55
+
56
+ @transport.send_request("tools/call", {
57
+ name: tool_name,
58
+ arguments: arguments
59
+ })
60
+ end
61
+
62
+ # Gracefully disconnects from the MCP server.
63
+ #
64
+ # @return [void]
65
+ def disconnect!
66
+ @transport.stop!
67
+ @initialized = false
68
+ @tools_cache = nil
69
+ end
70
+
71
+ # Returns whether the client is connected and the transport is alive.
72
+ #
73
+ # @return [Boolean]
74
+ def connected?
75
+ @initialized && @transport.alive?
76
+ end
77
+
78
+ class << self
79
+ # Factory method that creates a Client with the appropriate transport
80
+ # based on the server configuration.
81
+ #
82
+ # Configs with a :url key use SSETransport; all others use StdioTransport.
83
+ #
84
+ # @param server_config [Hash] configuration hash with :name, :command/:url, :args, :env
85
+ # @return [Client]
86
+ def from_config(server_config)
87
+ name = server_config[:name]
88
+
89
+ transport = if server_config[:url]
90
+ SSETransport.new(
91
+ url: server_config[:url],
92
+ timeout: server_config[:timeout] || SSETransport::DEFAULT_TIMEOUT
93
+ )
94
+ else
95
+ StdioTransport.new(
96
+ command: server_config[:command],
97
+ args: server_config[:args] || [],
98
+ env: server_config[:env] || {},
99
+ timeout: server_config[:timeout] || StdioTransport::DEFAULT_TIMEOUT
100
+ )
101
+ end
102
+
103
+ new(name: name, transport: transport)
104
+ end
105
+ end
106
+
107
+ private
108
+
109
+ def perform_initialize
110
+ result = @transport.send_request("initialize", {
111
+ protocolVersion: "2024-11-05",
112
+ capabilities: {
113
+ tools: {}
114
+ },
115
+ clientInfo: {
116
+ name: "rubyn-code",
117
+ version: RubynCode::VERSION
118
+ }
119
+ })
120
+
121
+ @server_info = result&.dig("serverInfo")
122
+ @server_capabilities = result&.dig("capabilities")
123
+
124
+ @transport.send_notification("notifications/initialized") if @transport.respond_to?(:send_notification)
125
+ end
126
+
127
+ def discover_tools
128
+ ensure_connected!
129
+
130
+ result = @transport.send_request("tools/list")
131
+ result&.fetch("tools", []) || []
132
+ end
133
+
134
+ def ensure_connected!
135
+ raise ClientError, "Client '#{@name}' is not connected. Call #connect! first." unless @initialized
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module RubynCode
6
+ module MCP
7
+ # Parses MCP server configuration from .rubyn-code/mcp.json in the project directory.
8
+ #
9
+ # Expected JSON format:
10
+ # {
11
+ # "mcpServers": {
12
+ # "server-name": {
13
+ # "command": "npx",
14
+ # "args": ["-y", "@modelcontextprotocol/server-github"],
15
+ # "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}" }
16
+ # }
17
+ # }
18
+ # }
19
+ module Config
20
+ CONFIG_FILENAME = ".rubyn-code/mcp.json"
21
+
22
+ ENV_VAR_PATTERN = /\$\{([^}]+)\}/
23
+
24
+ class << self
25
+ # Reads and parses the MCP server configuration for a project.
26
+ #
27
+ # @param project_path [String] root directory of the project
28
+ # @return [Array<Hash>] array of server configs with keys :name, :command, :args, :env
29
+ def load(project_path)
30
+ config_path = File.join(project_path, CONFIG_FILENAME)
31
+ return [] unless File.exist?(config_path)
32
+
33
+ raw = File.read(config_path)
34
+ data = JSON.parse(raw)
35
+ servers = data["mcpServers"] || {}
36
+
37
+ servers.map do |name, server_def|
38
+ {
39
+ name: name,
40
+ command: server_def["command"],
41
+ args: Array(server_def["args"]),
42
+ env: expand_env(server_def["env"] || {})
43
+ }
44
+ end
45
+ rescue JSON::ParserError => e
46
+ warn "[MCP::Config] Failed to parse #{config_path}: #{e.message}"
47
+ []
48
+ rescue SystemCallError => e
49
+ warn "[MCP::Config] Could not read #{config_path}: #{e.message}"
50
+ []
51
+ end
52
+
53
+ private
54
+
55
+ # Expands environment variable references (${VAR_NAME}) in config values.
56
+ #
57
+ # @param env_hash [Hash<String, String>] raw env key-value pairs
58
+ # @return [Hash<String, String>] expanded env key-value pairs
59
+ def expand_env(env_hash)
60
+ env_hash.each_with_object({}) do |(key, value), result|
61
+ result[key] = expand_value(value)
62
+ end
63
+ end
64
+
65
+ # Replaces ${VAR} patterns with actual environment variable values.
66
+ #
67
+ # @param value [String] a string potentially containing ${VAR} references
68
+ # @return [String] the string with env vars expanded
69
+ def expand_value(value)
70
+ return value unless value.is_a?(String)
71
+
72
+ value.gsub(ENV_VAR_PATTERN) do
73
+ env_name = ::Regexp.last_match(1)
74
+ ENV.fetch(env_name) do
75
+ warn "[MCP::Config] Environment variable #{env_name} is not set"
76
+ ""
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,225 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module RubynCode
8
+ module MCP
9
+ # Communicates with a remote MCP server via HTTP Server-Sent Events (SSE).
10
+ #
11
+ # On #start!, the transport establishes a long-lived GET connection to the
12
+ # SSE endpoint. The server responds with an `endpoint` event containing
13
+ # the URL for JSON-RPC POST requests. Subsequent requests are sent via
14
+ # POST, and responses arrive as SSE events on the GET stream.
15
+ class SSETransport
16
+ DEFAULT_TIMEOUT = 30 # seconds
17
+
18
+ TransportError = Class.new(RubynCode::Error)
19
+ TimeoutError = Class.new(TransportError)
20
+
21
+ # @param url [String] the SSE endpoint URL of the MCP server
22
+ # @param timeout [Integer] default timeout in seconds per request
23
+ def initialize(url:, timeout: DEFAULT_TIMEOUT)
24
+ @url = url
25
+ @timeout = timeout
26
+ @request_id = 0
27
+ @mutex = Mutex.new
28
+ @post_endpoint = nil
29
+ @pending_responses = {}
30
+ @connected = false
31
+ @sse_thread = nil
32
+ end
33
+
34
+ # Establishes the SSE connection and waits for the endpoint event.
35
+ #
36
+ # @return [void]
37
+ # @raise [TransportError] if the connection cannot be established
38
+ def start!
39
+ raise TransportError, "Transport already started" if @connected
40
+
41
+ @pending_responses = {}
42
+ @sse_thread = Thread.new { run_sse_listener }
43
+
44
+ # Wait for the endpoint event with a timeout
45
+ deadline = Time.now + @timeout
46
+ sleep(0.1) until @post_endpoint || Time.now > deadline
47
+
48
+ unless @post_endpoint
49
+ stop!
50
+ raise TransportError, "MCP server did not provide an endpoint within #{@timeout}s"
51
+ end
52
+
53
+ @connected = true
54
+ end
55
+
56
+ # Sends a JSON-RPC 2.0 request via HTTP POST and waits for the response.
57
+ #
58
+ # @param method [String] the JSON-RPC method name
59
+ # @param params [Hash] parameters for the request
60
+ # @return [Hash] the parsed JSON-RPC result
61
+ # @raise [TransportError] on protocol or server errors
62
+ # @raise [TimeoutError] if the response is not received in time
63
+ def send_request(method, params = {})
64
+ raise TransportError, "Transport is not connected" unless @connected
65
+
66
+ id = next_request_id
67
+ queue = Queue.new
68
+ @mutex.synchronize { @pending_responses[id] = queue }
69
+
70
+ request = {
71
+ jsonrpc: "2.0",
72
+ id: id,
73
+ method: method,
74
+ params: params
75
+ }
76
+
77
+ post_request(request)
78
+ wait_for_response(id, queue)
79
+ end
80
+
81
+ # Closes the SSE connection and cleans up resources.
82
+ #
83
+ # @return [void]
84
+ def stop!
85
+ @connected = false
86
+ @sse_thread&.kill
87
+ @sse_thread = nil
88
+ @post_endpoint = nil
89
+ @pending_responses.clear
90
+ end
91
+
92
+ # Checks whether the transport is connected.
93
+ #
94
+ # @return [Boolean]
95
+ def alive?
96
+ @connected && @sse_thread&.alive?
97
+ end
98
+
99
+ private
100
+
101
+ def next_request_id
102
+ @mutex.synchronize { @request_id += 1 }
103
+ end
104
+
105
+ def base_url
106
+ uri = URI.parse(@url)
107
+ "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port != uri.default_port}"
108
+ end
109
+
110
+ def connection
111
+ @connection ||= Faraday.new(url: base_url) do |f|
112
+ f.options.timeout = @timeout
113
+ f.options.open_timeout = @timeout
114
+ f.headers["Content-Type"] = "application/json"
115
+ f.adapter Faraday.default_adapter
116
+ end
117
+ end
118
+
119
+ def post_request(request)
120
+ response = connection.post(@post_endpoint) do |req|
121
+ req.body = JSON.generate(request)
122
+ end
123
+
124
+ unless response.success?
125
+ raise TransportError, "MCP server returned HTTP #{response.status}: #{response.body}"
126
+ end
127
+ rescue Faraday::Error => e
128
+ raise TransportError, "Failed to send request to MCP server: #{e.message}"
129
+ end
130
+
131
+ def wait_for_response(id, queue)
132
+ result = nil
133
+ begin
134
+ Timeout.timeout(@timeout, TimeoutError, "MCP server did not respond within #{@timeout}s") do
135
+ result = queue.pop
136
+ end
137
+ ensure
138
+ @mutex.synchronize { @pending_responses.delete(id) }
139
+ end
140
+
141
+ if result.is_a?(Hash) && result.key?("error")
142
+ err = result["error"]
143
+ raise TransportError, "MCP error (#{err['code']}): #{err['message']}"
144
+ end
145
+
146
+ result
147
+ end
148
+
149
+ def run_sse_listener
150
+ sse_connection = Faraday.new(url: base_url) do |f|
151
+ f.options.timeout = nil # Keep-alive
152
+ f.options.open_timeout = @timeout
153
+ f.headers["Accept"] = "text/event-stream"
154
+ f.adapter Faraday.default_adapter
155
+ end
156
+
157
+ buffer = +""
158
+
159
+ sse_connection.get(@url) do |req|
160
+ req.options.on_data = proc do |chunk, _bytes, _env|
161
+ buffer << chunk
162
+ process_sse_buffer(buffer)
163
+ end
164
+ end
165
+ rescue Faraday::Error => e
166
+ @connected = false
167
+ warn "[MCP::SSETransport] SSE connection lost: #{e.message}"
168
+ end
169
+
170
+ def process_sse_buffer(buffer)
171
+ while (idx = buffer.index("\n\n"))
172
+ raw_event = buffer.slice!(0, idx + 2)
173
+ event = parse_sse_event(raw_event)
174
+ handle_sse_event(event) if event
175
+ end
176
+ end
177
+
178
+ def parse_sse_event(raw)
179
+ event_type = nil
180
+ data_lines = []
181
+
182
+ raw.each_line do |line|
183
+ line = line.chomp
184
+ if line.start_with?("event:")
185
+ event_type = line.sub("event:", "").strip
186
+ elsif line.start_with?("data:")
187
+ data_lines << line.sub("data:", "").strip
188
+ end
189
+ end
190
+
191
+ return nil if data_lines.empty?
192
+
193
+ { type: event_type, data: data_lines.join("\n") }
194
+ end
195
+
196
+ def handle_sse_event(event)
197
+ case event[:type]
198
+ when "endpoint"
199
+ @post_endpoint = event[:data]
200
+ when "message"
201
+ dispatch_message(event[:data])
202
+ else
203
+ dispatch_message(event[:data])
204
+ end
205
+ end
206
+
207
+ def dispatch_message(data)
208
+ message = JSON.parse(data)
209
+ return unless message.is_a?(Hash) && message.key?("id")
210
+
211
+ id = message["id"]
212
+ queue = @mutex.synchronize { @pending_responses[id] }
213
+ return unless queue
214
+
215
+ if message.key?("error")
216
+ queue.push(message)
217
+ else
218
+ queue.push(message["result"])
219
+ end
220
+ rescue JSON::ParserError
221
+ # Ignore malformed messages
222
+ end
223
+ end
224
+ end
225
+ end