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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- 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
|