rubyn-code 0.5.0 → 0.7.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 (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +182 -11
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/app.rb +2 -2
  17. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  18. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  19. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  20. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  21. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  22. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  23. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  24. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  25. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  26. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  27. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  28. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  29. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  30. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  31. data/lib/rubyn_code/cli/commands/megaplan.rb +50 -0
  32. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  33. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  34. data/lib/rubyn_code/cli/first_run.rb +1 -1
  35. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  36. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  37. data/lib/rubyn_code/cli/renderer.rb +3 -2
  38. data/lib/rubyn_code/cli/repl.rb +37 -14
  39. data/lib/rubyn_code/cli/repl_commands.rb +77 -2
  40. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  41. data/lib/rubyn_code/cli/setup.rb +13 -0
  42. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  43. data/lib/rubyn_code/cli/version_check.rb +10 -3
  44. data/lib/rubyn_code/config/defaults.rb +13 -1
  45. data/lib/rubyn_code/config/schema.json +4 -0
  46. data/lib/rubyn_code/config/settings.rb +17 -2
  47. data/lib/rubyn_code/context/manager.rb +29 -12
  48. data/lib/rubyn_code/debug.rb +11 -5
  49. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  50. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  51. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  52. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  53. data/lib/rubyn_code/hooks/response.rb +83 -0
  54. data/lib/rubyn_code/hooks/runner.rb +61 -3
  55. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  56. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  57. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +65 -0
  58. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +22 -0
  59. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +53 -0
  60. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +41 -0
  61. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  62. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +143 -0
  63. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  64. data/lib/rubyn_code/ide/handlers.rb +17 -2
  65. data/lib/rubyn_code/ide/protocol.rb +15 -0
  66. data/lib/rubyn_code/ide/server.rb +39 -1
  67. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  68. data/lib/rubyn_code/learning/porter.rb +129 -0
  69. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  70. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  71. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  72. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  73. data/lib/rubyn_code/llm/model_router.rb +2 -2
  74. data/lib/rubyn_code/mcp/client.rb +59 -0
  75. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  76. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  77. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  78. data/lib/rubyn_code/megaplan/ci_recovery.rb +104 -0
  79. data/lib/rubyn_code/megaplan/interview_session.rb +250 -0
  80. data/lib/rubyn_code/megaplan/plan_proposer.rb +153 -0
  81. data/lib/rubyn_code/memory/search.rb +9 -5
  82. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  83. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  84. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  85. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  86. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  87. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  88. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  89. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  90. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  91. data/lib/rubyn_code/teams/manager.rb +83 -5
  92. data/lib/rubyn_code/teams/teammate.rb +5 -1
  93. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  94. data/lib/rubyn_code/tools/executor.rb +5 -3
  95. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  96. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  97. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  98. data/lib/rubyn_code/tools/web_search.rb +4 -1
  99. data/lib/rubyn_code/version.rb +1 -1
  100. data/lib/rubyn_code.rb +53 -2
  101. data/skills/megaplan/megaplan.md +156 -0
  102. data/skills/rubyn_self_test.md +322 -14
  103. data/skills/self_test/chisel_smoke.rb +84 -0
  104. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  105. metadata +49 -4
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'securerandom'
5
+
6
+ module RubynCode
7
+ module Learning
8
+ # Exports and imports learned instincts so a user can carry their
9
+ # accumulated learnings to another machine. Instincts live in SQLite under
10
+ # ~/.rubyn-code; this serializes them to a portable JSON file and loads
11
+ # them back, regenerating ids and de-duplicating by (project_path, pattern).
12
+ module Porter
13
+ FORMAT_VERSION = 1
14
+ # Columns carried across machines (id is regenerated on import).
15
+ COLUMNS = %w[
16
+ project_path pattern context_tags confidence decay_rate
17
+ times_applied times_helpful created_at updated_at
18
+ ].freeze
19
+
20
+ class Error < RubynCode::Error; end
21
+
22
+ class << self
23
+ # Export instincts to a JSON file.
24
+ #
25
+ # @param db [DB::Connection]
26
+ # @param path [String] destination file
27
+ # @param project_path [String, nil] limit to one project, or nil for all
28
+ # @return [Integer] number of instincts exported
29
+ def export(db:, path:, project_path: nil)
30
+ rows = fetch(db, project_path)
31
+ payload = { 'version' => FORMAT_VERSION, 'instincts' => rows }
32
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
33
+ rows.size
34
+ end
35
+
36
+ # Import instincts from a JSON file.
37
+ #
38
+ # @param db [DB::Connection]
39
+ # @param path [String] source file
40
+ # @param remap_project [String, nil] override every row's project_path
41
+ # (use the current project so imported learnings apply here)
42
+ # @return [Hash] { imported:, skipped:, total: }
43
+ def import(db:, path:, remap_project: nil)
44
+ raise Error, "File not found: #{path}" unless File.file?(path)
45
+
46
+ payload = parse(path)
47
+ instincts = Array(payload['instincts'])
48
+ imported = instincts.count { |row| import_row(db, row, remap_project) }
49
+
50
+ { imported: imported, skipped: instincts.size - imported, total: instincts.size }
51
+ end
52
+
53
+ # @return [Hash] { count:, projects: } summary for display
54
+ def stats(db, project_path: nil)
55
+ rows = fetch(db, project_path)
56
+ { count: rows.size, projects: rows.map { |r| r['project_path'] }.uniq.size }
57
+ end
58
+
59
+ private
60
+
61
+ def fetch(db, project_path)
62
+ select = "SELECT #{COLUMNS.join(', ')} FROM instincts"
63
+ if project_path
64
+ db.query("#{select} WHERE project_path = ?", [project_path]).to_a
65
+ else
66
+ db.query(select).to_a
67
+ end
68
+ end
69
+
70
+ def parse(path)
71
+ payload = JSON.parse(File.read(path))
72
+ raise Error, 'Not a Rubyn learnings file' unless payload.is_a?(Hash) && payload.key?('instincts')
73
+
74
+ version = payload['version'].to_i
75
+ raise Error, "Unsupported export version: #{version}" if version > FORMAT_VERSION
76
+
77
+ payload
78
+ rescue JSON::ParserError => e
79
+ raise Error, "Invalid JSON: #{e.message}"
80
+ end
81
+
82
+ # @return [Boolean] true if inserted, false if skipped (duplicate)
83
+ def import_row(db, row, remap_project)
84
+ project = remap_project || row['project_path']
85
+ return false if project.to_s.empty? || row['pattern'].to_s.empty?
86
+ return false if exists?(db, project, row['pattern'])
87
+
88
+ insert(db, row, project)
89
+ true
90
+ rescue StandardError => e
91
+ RubynCode::Debug.warn("Skipping instinct import: #{e.message}")
92
+ false
93
+ end
94
+
95
+ def exists?(db, project, pattern)
96
+ db.query(
97
+ 'SELECT 1 FROM instincts WHERE project_path = ? AND pattern = ? LIMIT 1',
98
+ [project, pattern]
99
+ ).to_a.any?
100
+ end
101
+
102
+ def insert(db, row, project)
103
+ now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
104
+ db.execute(
105
+ <<~SQL.tr("\n", ' ').strip,
106
+ INSERT INTO instincts (id, project_path, pattern, context_tags,
107
+ confidence, decay_rate, times_applied, times_helpful, created_at, updated_at)
108
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
109
+ SQL
110
+ [
111
+ SecureRandom.uuid, project, row['pattern'], normalize_tags(row['context_tags']),
112
+ (row['confidence'] || 0.5).to_f, (row['decay_rate'] || 0.05).to_f,
113
+ (row['times_applied'] || 0).to_i, (row['times_helpful'] || 0).to_i,
114
+ row['created_at'] || now, row['updated_at'] || now
115
+ ]
116
+ )
117
+ end
118
+
119
+ # context_tags is stored as a JSON string column; accept either a JSON
120
+ # string or an array from the export file.
121
+ def normalize_tags(tags)
122
+ return tags if tags.is_a?(String)
123
+
124
+ JSON.generate(Array(tags))
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
3
  require 'json'
5
4
  require 'securerandom'
6
5
  require_relative '../message_builder'
@@ -8,9 +7,11 @@ require_relative '../message_builder'
8
7
  module RubynCode
9
8
  module LLM
10
9
  module Adapters
10
+ # rubocop:disable Metrics/ClassLength -- auth helpers + streaming + finalize are all needed
11
11
  class Anthropic < Base
12
12
  include JsonParsing
13
13
  include PromptCaching
14
+ include TokenCaching
14
15
 
15
16
  API_URL = 'https://api.anthropic.com/v1/messages'
16
17
  ANTHROPIC_VERSION = '2023-06-01'
@@ -18,8 +19,9 @@ module RubynCode
18
19
  RETRY_DELAYS = [2, 5, 10].freeze
19
20
 
20
21
  AVAILABLE_MODELS = %w[
22
+ claude-fable-5
23
+ claude-opus-4-8
21
24
  claude-sonnet-4-20250514
22
- claude-opus-4-6
23
25
  claude-haiku-4-20250506
24
26
  ].freeze
25
27
 
@@ -46,30 +48,73 @@ module RubynCode
46
48
  execute_with_retries(body, on_text)
47
49
  end
48
50
 
49
- private
51
+ # -- Auth helpers ----------------------------------------------
50
52
 
51
- def api_url
52
- API_URL
53
- end
53
+ # Validate the active auth path before sending the request.
54
+ # Raises Client::AuthExpiredError with a clear message instead
55
+ # of letting the HTTP request fail with a 401.
56
+ def ensure_valid_token!
57
+ return if token_valid? && access_token && !access_token.empty?
54
58
 
55
- # -- Auth ---------------------------------------------------------
59
+ return if !oauth_token? && api_key && !api_key.empty?
56
60
 
57
- def oauth_token?
58
- access_token.include?('sk-ant-oat')
61
+ raise Client::AuthExpiredError, 'No valid authentication configured'
59
62
  end
60
63
 
61
- def ensure_valid_token!
62
- return if Auth::TokenStore.valid?
64
+ # @return [Boolean] true when using the OAuth token (vs API key).
65
+ # A token from the keychain (or marked source: :keychain /
66
+ # :oauth) is treated as OAuth; a token from :env or
67
+ # :tokens_file is treated as a long-lived API key.
68
+ def oauth_token?
69
+ token = raw_access_token
70
+ return false unless token.is_a?(Hash)
63
71
 
64
- raise Client::AuthExpiredError,
65
- 'No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY.'
72
+ source = token[:source] || token['source']
73
+ [:keychain, 'keychain', :oauth].include?(source)
66
74
  end
67
75
 
76
+ # @return [String, nil] the access token in use.
77
+ # Accepts either a raw String token or a Hash like
78
+ # { access_token: "...", expires_at: ..., source: :keychain }.
79
+ # Memoized per-instance so that repeated calls during one
80
+ # request don't re-hit the token store.
68
81
  def access_token
69
- tokens = Auth::TokenStore.load
70
- raise Client::AuthExpiredError, 'No stored access token' unless tokens&.dig(:access_token)
82
+ return @access_token if defined?(@access_token)
83
+
84
+ token = raw_access_token
85
+ @access_token =
86
+ case token
87
+ when nil then nil
88
+ when String then token.empty? ? nil : token
89
+ when Hash then token[:access_token] || token['access_token']
90
+ end
91
+ end
92
+
93
+ # @return [String, nil] the API key (ANTHROPIC_API_KEY), or nil
94
+ def api_key
95
+ ENV.fetch('ANTHROPIC_API_KEY', nil)
96
+ end
97
+
98
+ # @return [Boolean] true if the token store says the active
99
+ # credential is still valid
100
+ def token_valid?
101
+ return true unless defined?(Auth::TokenStore)
102
+ return true unless Auth::TokenStore.respond_to?(:valid?)
103
+
104
+ Auth::TokenStore.valid?
105
+ end
106
+
107
+ def raw_access_token
108
+ return @raw_access_token if defined?(@raw_access_token)
71
109
 
72
- tokens[:access_token]
110
+ @raw_access_token =
111
+ (Auth::TokenStore.load if defined?(Auth::TokenStore) && Auth::TokenStore.respond_to?(:load))
112
+ end
113
+
114
+ private
115
+
116
+ def api_url
117
+ API_URL
73
118
  end
74
119
 
75
120
  # -- Request execution --------------------------------------------
@@ -144,6 +189,7 @@ module RubynCode
144
189
 
145
190
  error_msg = extract_error_message(error_chunks.join)
146
191
 
192
+ invalidate_token_cache! if response.status == 401
147
193
  raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
148
194
  raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
149
195
 
@@ -167,6 +213,7 @@ module RubynCode
167
213
  end
168
214
 
169
215
  def build_faraday_connection
216
+ require 'faraday'
170
217
  Faraday.new do |f|
171
218
  f.options.timeout = 300
172
219
  f.options.open_timeout = 30
@@ -227,6 +274,7 @@ module RubynCode
227
274
  error_type = body&.dig('error', 'type') || 'api_error'
228
275
 
229
276
  log_api_error(response)
277
+ invalidate_token_cache! if response.status == 401
230
278
  raise Client::AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
231
279
  raise Client::PromptTooLongError, "Prompt too long: #{error_msg}" if response.status == 413
232
280
 
@@ -270,5 +318,6 @@ module RubynCode
270
318
  end
271
319
  end
272
320
  end
321
+ # rubocop:enable Metrics/ClassLength
273
322
  end
274
323
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
3
  require 'json'
5
4
  require_relative '../message_builder'
6
5
 
@@ -141,6 +140,7 @@ module RubynCode
141
140
  end
142
141
 
143
142
  def build_faraday_connection
143
+ require 'faraday'
144
144
  Faraday.new do |f|
145
145
  f.options.timeout = 300
146
146
  f.options.open_timeout = 30
@@ -36,7 +36,11 @@ module RubynCode
36
36
  def add_message_cache_breakpoint(messages)
37
37
  return messages if messages.nil? || messages.empty?
38
38
 
39
- tagged = messages.map(&:dup)
39
+ # Only the last message gets the cache_control tag, so only it
40
+ # needs to be duped — copying the whole history every call is
41
+ # wasted work on long conversations.
42
+ tagged = messages.dup
43
+ tagged[-1] = tagged[-1].dup
40
44
  tag_last_message_content(tagged.last)
41
45
  tagged
42
46
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module LLM
5
+ module Adapters
6
+ # Cached access to the Anthropic credential.
7
+ #
8
+ # TokenStore.load shells out to the macOS keychain, so the loaded
9
+ # tokens are cached per adapter instance. The cache drops itself once
10
+ # the token nears expiry (picking up an externally refreshed
11
+ # credential) and must be invalidated on 401 responses.
12
+ module TokenCaching
13
+ private
14
+
15
+ def oauth_token?
16
+ return @oauth_token unless @oauth_token.nil?
17
+
18
+ @oauth_token = access_token.include?('sk-ant-oat')
19
+ end
20
+
21
+ def ensure_valid_token!
22
+ return if Auth::TokenStore.valid_tokens?(tokens)
23
+
24
+ raise Client::AuthExpiredError,
25
+ 'No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY.'
26
+ end
27
+
28
+ def access_token
29
+ token = tokens&.fetch(:access_token, nil)
30
+ raise Client::AuthExpiredError, 'No stored access token' unless token
31
+
32
+ token
33
+ end
34
+
35
+ def tokens
36
+ invalidate_token_cache! if token_cache_stale?
37
+ @tokens ||= Auth::TokenStore.load
38
+ end
39
+
40
+ def token_cache_stale?
41
+ expires_at = @tokens&.fetch(:expires_at, nil)
42
+ return false unless expires_at
43
+
44
+ expires_at <= Time.now + Auth::TokenStore::EXPIRY_BUFFER_SECONDS
45
+ end
46
+
47
+ def invalidate_token_cache!
48
+ @tokens = nil
49
+ @oauth_token = nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -14,7 +14,7 @@ module RubynCode
14
14
  # models:
15
15
  # cheap: claude-haiku-4-5
16
16
  # mid: claude-sonnet-4-6
17
- # top: claude-opus-4-6
17
+ # top: claude-opus-4-8
18
18
  # openai:
19
19
  # env_key: OPENAI_API_KEY
20
20
  # models:
@@ -50,7 +50,7 @@ module RubynCode
50
50
  %w[openai gpt-5.4-mini]
51
51
  ].freeze,
52
52
  top: [
53
- %w[anthropic claude-opus-4-6],
53
+ %w[anthropic claude-opus-4-8],
54
54
  %w[openai gpt-5.4]
55
55
  ].freeze
56
56
  }.freeze
@@ -60,6 +60,63 @@ module RubynCode
60
60
  })
61
61
  end
62
62
 
63
+ # Returns the resources advertised by the server (empty unless the
64
+ # server declared the `resources` capability). Each entry is a Hash with
65
+ # "uri", "name", and optionally "description"/"mimeType".
66
+ #
67
+ # @return [Array<Hash>]
68
+ def resources
69
+ return [] unless supports_resources?
70
+
71
+ @resources ||= @transport.send_request('resources/list')&.fetch('resources', []) || []
72
+ rescue StandardError => e
73
+ RubynCode::Debug.warn("MCP '#{@name}' resources/list failed: #{e.message}")
74
+ []
75
+ end
76
+
77
+ # Reads a single resource by URI.
78
+ #
79
+ # @param uri [String]
80
+ # @return [Hash] result with a "contents" array
81
+ def read_resource(uri)
82
+ ensure_connected!
83
+ @transport.send_request('resources/read', { uri: uri })
84
+ end
85
+
86
+ # Returns the prompts advertised by the server (empty unless the server
87
+ # declared the `prompts` capability). Each entry has "name" and
88
+ # optionally "description"/"arguments".
89
+ #
90
+ # @return [Array<Hash>]
91
+ def prompts
92
+ return [] unless supports_prompts?
93
+
94
+ @prompts ||= @transport.send_request('prompts/list')&.fetch('prompts', []) || []
95
+ rescue StandardError => e
96
+ RubynCode::Debug.warn("MCP '#{@name}' prompts/list failed: #{e.message}")
97
+ []
98
+ end
99
+
100
+ # Fetches a prompt, expanding its template with the given arguments.
101
+ #
102
+ # @param name [String]
103
+ # @param arguments [Hash]
104
+ # @return [Hash] result with "messages" (and optionally "description")
105
+ def get_prompt(name, arguments = {})
106
+ ensure_connected!
107
+ @transport.send_request('prompts/get', { name: name, arguments: arguments })
108
+ end
109
+
110
+ # @return [Boolean] whether the server advertised the resources capability
111
+ def supports_resources?
112
+ @server_capabilities.is_a?(Hash) && @server_capabilities.key?('resources')
113
+ end
114
+
115
+ # @return [Boolean] whether the server advertised the prompts capability
116
+ def supports_prompts?
117
+ @server_capabilities.is_a?(Hash) && @server_capabilities.key?('prompts')
118
+ end
119
+
63
120
  # Gracefully disconnects from the MCP server.
64
121
  #
65
122
  # @return [void]
@@ -67,6 +124,8 @@ module RubynCode
67
124
  @transport.stop!
68
125
  @initialized = false
69
126
  @tools = nil
127
+ @resources = nil
128
+ @prompts = nil
70
129
  end
71
130
 
72
131
  # Returns whether the client is connected and the transport is alive.
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module MCP
5
+ # Bridges an MCP server's *resources* and *prompts* into native RubynCode
6
+ # tools — the non-"tools" half of the MCP surface. Split out of ToolBridge
7
+ # so tool bridging and resource/prompt bridging each stay one focused job.
8
+ #
9
+ # Each capability registers a single tool whose description lists what's
10
+ # available (resource URIs / prompt names) and reads or fetches any of them.
11
+ module ServerExtrasBridge
12
+ class << self
13
+ # If the server exposes resources, register one tool that lists the
14
+ # available URIs (in its description) and reads any of them.
15
+ #
16
+ # @return [Array<Class>] the [read_resource] tool class, or [] if none
17
+ def bridge_resources(mcp_client)
18
+ resources = safe_list(mcp_client, :resources)
19
+ return [] if resources.empty?
20
+
21
+ klass = create_resource_tool(mcp_client, resources)
22
+ Tools::Registry.register(klass)
23
+ [klass]
24
+ end
25
+
26
+ # If the server exposes prompts, register one tool that lists the
27
+ # available prompt names and fetches any of them (with arguments).
28
+ #
29
+ # @return [Array<Class>] the [get_prompt] tool class, or [] if none
30
+ def bridge_prompts(mcp_client)
31
+ prompts = safe_list(mcp_client, :prompts)
32
+ return [] if prompts.empty?
33
+
34
+ klass = create_prompt_tool(mcp_client, prompts)
35
+ Tools::Registry.register(klass)
36
+ [klass]
37
+ end
38
+
39
+ private
40
+
41
+ def safe_list(mcp_client, method)
42
+ Array(mcp_client.public_send(method))
43
+ rescue StandardError
44
+ []
45
+ end
46
+
47
+ def create_resource_tool(mcp_client, resources)
48
+ listing = resources.map { |r| "- #{r['uri']}#{r['name'] && " (#{r['name']})"}" }.join("\n")
49
+ description = "Read a resource from MCP server '#{mcp_client.name}'. Available resources:\n#{listing}"
50
+ params = { uri: { type: :string, description: 'The resource URI to read', required: true } }
51
+
52
+ tool_name = "mcp_#{ToolBridge.sanitize_name(mcp_client.name)}_read_resource"
53
+ build_dynamic_tool(tool_name, description, params) do |args|
54
+ format_resource_contents(mcp_client.read_resource(args[:uri] || args['uri']))
55
+ end
56
+ end
57
+
58
+ def create_prompt_tool(mcp_client, prompts)
59
+ listing = prompts.map { |p| "- #{p['name']}#{p['description'] && ": #{p['description']}"}" }.join("\n")
60
+ description = "Fetch a prompt from MCP server '#{mcp_client.name}'. Available prompts:\n#{listing}"
61
+ params = {
62
+ name: { type: :string, description: 'The prompt name', required: true },
63
+ arguments: { type: :object, description: 'Prompt arguments (optional)', required: false }
64
+ }
65
+
66
+ tool_name = "mcp_#{ToolBridge.sanitize_name(mcp_client.name)}_get_prompt"
67
+ build_dynamic_tool(tool_name, description, params) do |args|
68
+ result = mcp_client.get_prompt(args[:name] || args['name'], args[:arguments] || args['arguments'] || {})
69
+ format_prompt_messages(result)
70
+ end
71
+ end
72
+
73
+ # Build a Tools::Base subclass whose #execute delegates to the block.
74
+ def build_dynamic_tool(tool_name, description, parameters, &handler)
75
+ Class.new(Tools::Base) do
76
+ const_set(:TOOL_NAME, tool_name)
77
+ const_set(:DESCRIPTION, description)
78
+ const_set(:PARAMETERS, parameters)
79
+ const_set(:RISK_LEVEL, :external)
80
+ const_set(:REQUIRES_CONFIRMATION, true)
81
+
82
+ define_method(:execute) { |**params| handler.call(params) }
83
+ end
84
+ end
85
+
86
+ def format_resource_contents(result)
87
+ contents = result.is_a?(Hash) ? Array(result['contents']) : []
88
+ return result.to_s if contents.empty?
89
+
90
+ contents.map { |c| c['text'] || "[binary resource: #{c['uri']} #{c['mimeType']}]" }.join("\n")
91
+ end
92
+
93
+ def format_prompt_messages(result)
94
+ messages = result.is_a?(Hash) ? Array(result['messages']) : []
95
+ return result.to_s if messages.empty?
96
+
97
+ messages.map { |m| "#{m['role']}: #{prompt_message_text(m['content'])}" }.join("\n\n")
98
+ end
99
+
100
+ def prompt_message_text(content)
101
+ case content
102
+ when Hash then content['text'] || content.to_s
103
+ when Array then content.map { |b| b.is_a?(Hash) ? b['text'] : b }.compact.join("\n")
104
+ else content.to_s
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'faraday'
4
3
  require 'json'
5
4
  require 'uri'
6
5
 
@@ -111,6 +110,7 @@ module RubynCode
111
110
  end
112
111
 
113
112
  def connection
113
+ require 'faraday'
114
114
  @connection ||= Faraday.new(url: base_url) do |f|
115
115
  f.options.timeout = @timeout
116
116
  f.options.open_timeout = @timeout
@@ -165,6 +165,7 @@ module RubynCode
165
165
  end
166
166
 
167
167
  def build_sse_connection
168
+ require 'faraday'
168
169
  Faraday.new(url: base_url) do |f|
169
170
  f.options.timeout = nil
170
171
  f.options.open_timeout = @timeout
@@ -10,6 +10,9 @@ module RubynCode
10
10
  # - Has RISK_LEVEL = :external
11
11
  # - Delegates #execute to the MCP client's #call_tool
12
12
  # - Registers itself with Tools::Registry
13
+ #
14
+ # A server's resources and prompts are bridged separately by
15
+ # {ServerExtrasBridge}, which #bridge folds into the returned classes.
13
16
  module ToolBridge
14
17
  JSON_TYPE_MAP = {
15
18
  'string' => :string, 'integer' => :integer, 'number' => :number,
@@ -18,17 +21,24 @@ module RubynCode
18
21
 
19
22
  class << self
20
23
  # Discovers tools from an MCP client and creates corresponding
21
- # RubynCode tool classes.
24
+ # RubynCode tool classes (plus any resource/prompt bridge tools).
22
25
  #
23
26
  # @param mcp_client [MCP::Client] a connected MCP client
24
27
  # @return [Array<Class>] the dynamically created tool classes
25
28
  def bridge(mcp_client)
26
- tools = mcp_client.tools
27
- return [] if tools.nil? || tools.empty?
29
+ classes = Array(mcp_client.tools).map { |tool_def| build_tool_class(mcp_client, tool_def) }
30
+ classes.concat(ServerExtrasBridge.bridge_resources(mcp_client))
31
+ classes.concat(ServerExtrasBridge.bridge_prompts(mcp_client))
32
+ classes
33
+ end
28
34
 
29
- tools.map do |tool_def|
30
- build_tool_class(mcp_client, tool_def)
31
- end
35
+ # Sanitizes a tool name into a Ruby-friendly identifier. Public so
36
+ # {ServerExtrasBridge} names its resource/prompt tools the same way.
37
+ #
38
+ # @param name [String] the original tool name
39
+ # @return [String] sanitized name
40
+ def sanitize_name(name)
41
+ name.to_s.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_').downcase
32
42
  end
33
43
 
34
44
  private
@@ -119,14 +129,6 @@ module RubynCode
119
129
  def map_json_type(json_type)
120
130
  JSON_TYPE_MAP.fetch(json_type, :string)
121
131
  end
122
-
123
- # Sanitizes a tool name for use as a Ruby-friendly identifier.
124
- #
125
- # @param name [String] the original tool name
126
- # @return [String] sanitized name
127
- def sanitize_name(name)
128
- name.to_s.gsub(/[^a-zA-Z0-9_]/, '_').gsub(/_+/, '_').downcase
129
- end
130
132
  end
131
133
  end
132
134
  end