aia 1.1.1 → 2.0.0.0.pre.beta2
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 +4 -4
- data/.envrc +9 -1
- data/.loki +11 -0
- data/.reek.yml +160 -0
- data/.rubocop.yml +116 -0
- data/.rubocop_strict.yml +15 -0
- data/.version +1 -1
- data/CHANGELOG.md +269 -55
- data/IMPLEMENTATION_PLAN.md +506 -0
- data/README.md +267 -239
- data/Rakefile +5 -5
- data/_typos.toml +10 -0
- data/aia.gemspec +92 -0
- data/architecture_review.md +314 -0
- data/bin/aia +16 -0
- data/config/aia.yml +13 -0
- data/docs/AGENTS.md +40 -0
- data/docs/advanced-prompting.md +67 -3
- data/docs/cli-reference.md +312 -56
- data/docs/configuration.md +130 -19
- data/docs/contributing.md +56 -2
- data/docs/directives-reference.md +593 -78
- data/docs/faq.md +85 -3
- data/docs/guides/available-models.md +1 -1
- data/docs/guides/basic-usage.md +6 -6
- data/docs/guides/chat.md +40 -16
- data/docs/guides/crew.md +239 -0
- data/docs/guides/executable-prompts.md +1 -1
- data/docs/guides/index.md +1 -0
- data/docs/guides/models.md +15 -0
- data/docs/index.md +29 -2
- data/docs/installation.md +44 -17
- data/docs/mcp-integration.md +40 -0
- data/docs/prompt_management.md +85 -86
- data/docs/security.md +47 -0
- data/docs/special_projects_guide.md +386 -0
- data/docs/tools-and-mcp-examples.md +23 -0
- data/docs/workflows-and-pipelines.md +84 -7
- data/examples/.gitignore +1 -0
- data/examples/00_setup_aia.sh +27 -44
- data/examples/11_multi_model.sh +4 -14
- data/examples/12_token_usage.sh +3 -12
- data/examples/18_tools.sh +10 -2
- data/examples/22_chat_mode.sh +0 -10
- data/examples/23_verify.sh +139 -0
- data/examples/24_decompose.sh +139 -0
- data/examples/25_spawn.sh +139 -0
- data/examples/26_debate.sh +97 -0
- data/examples/27_mention_routing.sh +157 -0
- data/examples/28_model_switching.sh +106 -0
- data/examples/29_agent_harness.sh +177 -0
- data/examples/README.md +65 -0
- data/examples/advanced_multi_robot_capabilities_without_examples.md +106 -0
- data/examples/aia_config.yml +1 -1
- data/examples/aia_config_orchestrator.yml +45 -0
- data/examples/common.sh +18 -6
- data/examples/context/tech_stack.md +2 -2
- data/examples/prompts_dir/roles/orchestrator.md +21 -0
- data/examples/requirements/sinatra_taskflow_app.md +139 -0
- data/examples/rules/01_classify_ruby.rb +16 -0
- data/examples/rules/02_prefer_claude_for_code.rb +19 -0
- data/examples/rules/03_gate_prompt_length.rb +19 -0
- data/examples/rules/04_tool_selection.rb +41 -0
- data/examples/rules/README.md +30 -0
- data/examples/run_all.sh +48 -15
- data/examples/tools/word_count_tool.rb +1 -1
- data/lib/AGENTS.md +57 -0
- data/lib/aia/chat_loop.rb +263 -167
- data/lib/aia/config/cli_parser.rb +217 -145
- data/lib/aia/config/defaults.yml +62 -33
- data/lib/aia/config/mcp_parser.rb +52 -51
- data/lib/aia/config/model_spec.rb +34 -2
- data/lib/aia/config/validator.rb +171 -216
- data/lib/aia/config.rb +111 -145
- data/lib/aia/content_extractor.rb +155 -0
- data/lib/aia/cost_calculator.rb +39 -0
- data/lib/aia/crew.rb +164 -0
- data/lib/aia/debate_handler.rb +174 -0
- data/lib/aia/delegate_handler.rb +116 -0
- data/lib/aia/directive.rb +43 -26
- data/lib/aia/directive_processor.rb +16 -7
- data/lib/aia/directives/configuration_directives.rb +214 -60
- data/lib/aia/directives/context_directives.rb +67 -52
- data/lib/aia/directives/execution_directives.rb +141 -4
- data/lib/aia/directives/model_directives.rb +163 -141
- data/lib/aia/directives/trakflow_directives.rb +62 -0
- data/lib/aia/directives/utility_directives.rb +227 -30
- data/lib/aia/directives/web_and_file_directives.rb +126 -77
- data/lib/aia/errors.rb +15 -0
- data/lib/aia/fact_asserter.rb +27 -0
- data/lib/aia/fzf.rb +9 -31
- data/lib/aia/handler_context.rb +17 -0
- data/lib/aia/handler_protocol.rb +19 -0
- data/lib/aia/history_transfer.rb +55 -0
- data/lib/aia/input_collector.rb +3 -3
- data/lib/aia/layered_orchestrator.rb +471 -0
- data/lib/aia/logger.rb +45 -25
- data/lib/aia/mcp_config_normalizer.rb +35 -0
- data/lib/aia/mcp_connection_manager.rb +315 -0
- data/lib/aia/mcp_discovery.rb +44 -0
- data/lib/aia/mcp_grouper.rb +33 -0
- data/lib/aia/mcp_server_config.rb +30 -0
- data/lib/aia/mcp_utility.rb +60 -0
- data/lib/aia/mention_router.rb +217 -0
- data/lib/aia/model_alias_registry.rb +97 -0
- data/lib/aia/model_switch_handler.rb +100 -0
- data/lib/aia/network_builder.rb +160 -0
- data/lib/aia/network_memory_manager.rb +55 -0
- data/lib/aia/patches/ruby_llm_streaming_error.rb +43 -0
- data/lib/aia/patches/ruby_llm_tool_error.rb +96 -0
- data/lib/aia/pipeline_orchestrator.rb +272 -0
- data/lib/aia/plugin_loader.rb +170 -0
- data/lib/aia/plugin_monitor.rb +211 -0
- data/lib/aia/prompt_decomposer.rb +159 -0
- data/lib/aia/prompt_handler.rb +56 -82
- data/lib/aia/robot_builder.rb +51 -0
- data/lib/aia/robot_factory.rb +338 -0
- data/lib/aia/robot_namer.rb +110 -0
- data/lib/aia/session.rb +87 -17
- data/lib/aia/session_tracker.rb +207 -0
- data/lib/aia/similarity_scorer.rb +41 -0
- data/lib/aia/skill_utils.rb +105 -1
- data/lib/aia/spawn_handler.rb +129 -0
- data/lib/aia/spawn_spec_parser.rb +65 -0
- data/lib/aia/special_mode_handler.rb +322 -0
- data/lib/aia/speech.rb +67 -0
- data/lib/aia/startup_coordinator.rb +151 -0
- data/lib/aia/streaming_runner.rb +172 -0
- data/lib/aia/system_prompt_assembler.rb +92 -0
- data/lib/aia/task_coordinator.rb +207 -0
- data/lib/aia/task_decomposer.rb +57 -0
- data/lib/aia/task_executor.rb +51 -0
- data/lib/aia/tfidf_math.rb +27 -0
- data/lib/aia/timing.rb +15 -0
- data/lib/aia/tool_filter/tfidf.rb +116 -0
- data/lib/aia/tool_filter/wordnet_expander.rb +127 -0
- data/lib/aia/tool_filter.rb +83 -0
- data/lib/aia/tool_filter_registry.rb +30 -0
- data/lib/aia/tool_filter_strategy.rb +146 -0
- data/lib/aia/tool_introspection.rb +17 -0
- data/lib/aia/tool_loader.rb +216 -0
- data/lib/aia/tool_utility.rb +30 -0
- data/lib/aia/tools/delegate_to_foreman_tool.rb +70 -0
- data/lib/aia/tools/recruit_robot_tool.rb +60 -0
- data/lib/aia/tools/reskill_robot_tool.rb +44 -0
- data/lib/aia/tools/task_board_tool.rb +115 -0
- data/lib/aia/trakflow_bridge.rb +175 -0
- data/lib/aia/turn_state.rb +95 -0
- data/lib/aia/ui_presenter.rb +182 -206
- data/lib/aia/utility.rb +136 -87
- data/lib/aia/{history_manager.rb → variable_input_collector.rb} +9 -9
- data/lib/aia/verification_network.rb +57 -0
- data/lib/aia.rb +124 -63
- data/mkdocs.yml +1 -0
- metadata +187 -58
- data/justfile +0 -215
- data/lib/aia/adapter/chat_execution.rb +0 -242
- data/lib/aia/adapter/error_handler.rb +0 -68
- data/lib/aia/adapter/gem_activator.rb +0 -57
- data/lib/aia/adapter/mcp_connector.rb +0 -274
- data/lib/aia/adapter/modality_handlers.rb +0 -167
- data/lib/aia/adapter/model_registry.rb +0 -81
- data/lib/aia/adapter/multi_model_chat.rb +0 -218
- data/lib/aia/adapter/provider_configurator.rb +0 -59
- data/lib/aia/adapter/tool_filter.rb +0 -85
- data/lib/aia/adapter/tool_loader.rb +0 -90
- data/lib/aia/chat_processor_service.rb +0 -178
- data/lib/aia/prompt_pipeline.rb +0 -183
- data/lib/aia/ruby_llm_adapter.rb +0 -95
- data/lib/extensions/openstruct_merge.rb +0 -48
- data/lib/extensions/ruby_llm/.irbrc +0 -56
- data/lib/extensions/ruby_llm/modalities.rb +0 -36
- data/lib/extensions/ruby_llm/provider_fix.rb +0 -79
- data/lib/refinements/string.rb +0 -16
- data/main.just +0 -76
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_config_normalizer.rb
|
|
4
|
+
#
|
|
5
|
+
# Normalizes a single MCP server config from AIA flat format to the nested
|
|
6
|
+
# transport format expected by robot_lab. Server selection/filtering is
|
|
7
|
+
# the responsibility of MCPDiscovery — this class only transforms shape.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class MCPConfigNormalizer
|
|
11
|
+
class << self
|
|
12
|
+
# Normalize a single MCP server config to robot_lab's nested transport format.
|
|
13
|
+
#
|
|
14
|
+
# @param server [Hash]
|
|
15
|
+
# @return [Hash]
|
|
16
|
+
def normalize(server)
|
|
17
|
+
server = server.is_a?(Hash) ? server.transform_keys(&:to_sym) : server.to_h.transform_keys(&:to_sym)
|
|
18
|
+
|
|
19
|
+
# Already in robot_lab format — pass through
|
|
20
|
+
return server if server[:transport]
|
|
21
|
+
|
|
22
|
+
# Legacy flat format: wrap command/args/env into transport
|
|
23
|
+
name = server[:name]
|
|
24
|
+
transport = { type: server[:type] || 'stdio' }
|
|
25
|
+
transport[:command] = server[:command] if server[:command]
|
|
26
|
+
transport[:args] = Array(server[:args]) if server[:args]
|
|
27
|
+
transport[:env] = server[:env] if server[:env]
|
|
28
|
+
|
|
29
|
+
result = { name: name, transport: transport }
|
|
30
|
+
result[:timeout] = server[:timeout] if server[:timeout]
|
|
31
|
+
result
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_connection_manager.rb
|
|
4
|
+
#
|
|
5
|
+
# Single owner for MCP connection state. Handles connecting to configured
|
|
6
|
+
# MCP servers concurrently, tracking successes/failures, and injecting
|
|
7
|
+
# results into robots.
|
|
8
|
+
|
|
9
|
+
require 'timeout'
|
|
10
|
+
require 'tty-spinner'
|
|
11
|
+
|
|
12
|
+
module AIA
|
|
13
|
+
# :reek:TooManyMethods -- one small helper per connection-lifecycle concern (connect, wrap tools, record outcomes)
|
|
14
|
+
class MCPConnectionManager
|
|
15
|
+
# Maximum seconds to wait for a single MCP server to connect.
|
|
16
|
+
DEFAULT_TIMEOUT = 30
|
|
17
|
+
|
|
18
|
+
attr_reader :connected_clients, :connected_tools, :failed_servers
|
|
19
|
+
|
|
20
|
+
def initialize
|
|
21
|
+
@connected_clients = {}
|
|
22
|
+
@connected_tools = []
|
|
23
|
+
@failed_servers = []
|
|
24
|
+
@server_tool_counts = {}
|
|
25
|
+
@connected = false
|
|
26
|
+
@mutex = Mutex.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Connect all configured MCP servers concurrently.
|
|
30
|
+
# Each server gets its own spinner line and thread.
|
|
31
|
+
#
|
|
32
|
+
# @param servers [Array<Hash>] MCP server configurations
|
|
33
|
+
# @return [self]
|
|
34
|
+
# :reek:TooManyStatements -- connection ceremony: spinner setup, thread launch, global timeout guard, summary logging
|
|
35
|
+
def connect_all(servers)
|
|
36
|
+
return self unless servers.is_a?(Array) && servers.any?
|
|
37
|
+
|
|
38
|
+
reset_connection_state
|
|
39
|
+
logger = AIA::LoggerManager.mcp_logger
|
|
40
|
+
server_names = servers.map { |s| s.is_a?(Hash) ? s[:name] : s.to_s }.compact
|
|
41
|
+
logger.info("MCP initialization: connecting #{servers.size} server(s): #{server_names.join(', ')}")
|
|
42
|
+
|
|
43
|
+
multi = TTY::Spinner::Multi.new("[:spinner] Connecting MCP servers", format: :dots, output: $stderr)
|
|
44
|
+
threads = launch_server_threads(servers, multi, logger)
|
|
45
|
+
|
|
46
|
+
# The global cap must never be smaller than the slowest single server's
|
|
47
|
+
# timeout, or concurrent connections get killed before they can finish.
|
|
48
|
+
global_limit = [global_connection_timeout, *servers.map { |s| server_timeout(s) }].max
|
|
49
|
+
Timeout.timeout(global_limit) { threads.each(&:join) }
|
|
50
|
+
|
|
51
|
+
@connected = true
|
|
52
|
+
logger.info("MCP initialization complete: #{@connected_clients.size} connected, #{@failed_servers.size} failed")
|
|
53
|
+
self
|
|
54
|
+
rescue Timeout::Error
|
|
55
|
+
handle_global_timeout(servers, global_limit)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Inject connected MCP clients and tools into robot(s).
|
|
59
|
+
#
|
|
60
|
+
# @param robot [RobotLab::Robot, RobotLab::Network] the robot or network
|
|
61
|
+
# @return [self]
|
|
62
|
+
def inject_into(robot)
|
|
63
|
+
clients, tools = @mutex.synchronize { [@connected_clients.dup, @connected_tools.dup] }
|
|
64
|
+
|
|
65
|
+
targets = robot.crew
|
|
66
|
+
|
|
67
|
+
targets.each do |target|
|
|
68
|
+
target.inject_mcp!(clients: clients, tools: tools)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Update AIA config with connection results.
|
|
75
|
+
#
|
|
76
|
+
# @return [self]
|
|
77
|
+
def update_config
|
|
78
|
+
connected_keys, tool_counts, failed = @mutex.synchronize do
|
|
79
|
+
[@connected_clients.keys.dup, @server_tool_counts.dup, @failed_servers.dup]
|
|
80
|
+
end
|
|
81
|
+
cfg = AIA.config
|
|
82
|
+
cfg.connected_mcp_servers = connected_keys
|
|
83
|
+
cfg.mcp_server_tool_counts = tool_counts
|
|
84
|
+
cfg.failed_mcp_servers = failed
|
|
85
|
+
self
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether connect_all has been called.
|
|
89
|
+
def connected?
|
|
90
|
+
@connected
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Whether any tools were collected (from config servers or Ruby require).
|
|
94
|
+
def any_tools?
|
|
95
|
+
@mutex.synchronize { @connected_tools.any? }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Absorb MCP clients registered in RubyLLM::MCP (e.g., via --require loading
|
|
99
|
+
# shared_tools/mcp/* files) that are not already tracked by this manager.
|
|
100
|
+
# Starts any unstarted clients, extracts their tools, and merges them into
|
|
101
|
+
# the connected state so inject_into will include them in the robot.
|
|
102
|
+
#
|
|
103
|
+
# @return [self]
|
|
104
|
+
# :reek:TooManyStatements -- per-client absorb loop: start if needed, extract tools, merge into connected state with error capture
|
|
105
|
+
def absorb_ruby_llm_mcp_clients
|
|
106
|
+
return self unless defined?(RubyLLM::MCP)
|
|
107
|
+
|
|
108
|
+
logger = AIA::LoggerManager.mcp_logger
|
|
109
|
+
|
|
110
|
+
RubyLLM::MCP.clients.each do |name, client|
|
|
111
|
+
next if @connected_clients.key?(name)
|
|
112
|
+
|
|
113
|
+
begin
|
|
114
|
+
client.start unless client.alive?
|
|
115
|
+
|
|
116
|
+
if client.alive?
|
|
117
|
+
tools = client.tools rescue []
|
|
118
|
+
@mutex.synchronize do
|
|
119
|
+
@connected_clients[name] = client
|
|
120
|
+
@server_tool_counts[name] = tools.size
|
|
121
|
+
@connected_tools.concat(tools)
|
|
122
|
+
end
|
|
123
|
+
logger.info("MCP: absorbed RubyLLM::MCP client '#{name}' (#{tools.size} tools)")
|
|
124
|
+
else
|
|
125
|
+
logger.warn("MCP: RubyLLM::MCP client '#{name}' not alive, skipping")
|
|
126
|
+
end
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
logger.warn("MCP: error absorbing RubyLLM::MCP client '#{name}': #{e.message}")
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
@connected = true unless RubyLLM::MCP.clients.empty?
|
|
133
|
+
self
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Find an MCP client by server name.
|
|
137
|
+
#
|
|
138
|
+
# @param name [String] server name
|
|
139
|
+
# @return [RobotLab::MCP::Client, nil]
|
|
140
|
+
def client(name)
|
|
141
|
+
@connected_clients[name]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Connected server names.
|
|
145
|
+
#
|
|
146
|
+
# @return [Array<String>]
|
|
147
|
+
def connected_server_names
|
|
148
|
+
@mutex.synchronize { @connected_clients.keys.dup }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Failed server names.
|
|
152
|
+
#
|
|
153
|
+
# @return [Array<String>]
|
|
154
|
+
def failed_server_names
|
|
155
|
+
@mutex.synchronize { @failed_servers.map { |f| f[:name] } }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Close all connected MCP clients and reset connection state.
|
|
159
|
+
# Errors from individual client closes are silently rescued —
|
|
160
|
+
# best-effort cleanup on process exit.
|
|
161
|
+
def close_all
|
|
162
|
+
clients = @mutex.synchronize { @connected_clients.dup }
|
|
163
|
+
clients.each_value do |client|
|
|
164
|
+
client.close
|
|
165
|
+
rescue StandardError
|
|
166
|
+
# best-effort — client may already be closed
|
|
167
|
+
end
|
|
168
|
+
@mutex.synchronize do
|
|
169
|
+
@connected_clients = {}
|
|
170
|
+
@connected_tools = []
|
|
171
|
+
@server_tool_counts = {}
|
|
172
|
+
@connected = false
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def reset_connection_state
|
|
179
|
+
@connected_clients = {}
|
|
180
|
+
@connected_tools = []
|
|
181
|
+
@failed_servers = []
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def launch_server_threads(servers, multi, logger)
|
|
185
|
+
servers.map do |server_config|
|
|
186
|
+
name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
|
|
187
|
+
spinner = multi.register("[:spinner] #{name}")
|
|
188
|
+
Thread.new(server_config, name, spinner) { |cfg, srv_name, sp| connect_one(cfg, srv_name, sp, logger) }
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def handle_global_timeout(servers, global_limit)
|
|
193
|
+
logger = AIA::LoggerManager.mcp_logger
|
|
194
|
+
logger.warn("MCP: Global connection timeout (#{global_limit}s) reached")
|
|
195
|
+
|
|
196
|
+
connected_names, failed_names = @mutex.synchronize do
|
|
197
|
+
[@connected_clients.keys.to_set, @failed_servers.to_set { |f| f[:name] }]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
stragglers = servers.filter_map do |server_config|
|
|
201
|
+
srv_name = server_config.is_a?(Hash) ? (server_config[:name] || server_config['name']) : server_config.to_s
|
|
202
|
+
next if connected_names.include?(srv_name) || failed_names.include?(srv_name)
|
|
203
|
+
|
|
204
|
+
{ name: srv_name, error: "global connection timeout (#{global_limit}s)" }
|
|
205
|
+
end
|
|
206
|
+
@mutex.synchronize { @failed_servers.concat(stragglers) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Read global MCP connection timeout from config.
|
|
210
|
+
# Falls back to 120s if config is unavailable.
|
|
211
|
+
def global_connection_timeout
|
|
212
|
+
AIA.config&.concurrency&.mcp_timeout.to_i.then { |v| v.positive? ? v : 120 }
|
|
213
|
+
rescue StandardError
|
|
214
|
+
120
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Connect a single MCP server, updating the spinner on completion.
|
|
218
|
+
# :reek:TooManyStatements -- one connect attempt with per-outcome reporting (success, refused, timeout, error)
|
|
219
|
+
def connect_one(server_config, name, spinner, logger)
|
|
220
|
+
timeout = server_timeout(server_config)
|
|
221
|
+
spinner.auto_spin
|
|
222
|
+
|
|
223
|
+
logger.info("MCP: connecting to '#{name}'...")
|
|
224
|
+
Timeout.timeout(timeout) do
|
|
225
|
+
client = RobotLab::MCP::Client.new(server_config)
|
|
226
|
+
client.connect
|
|
227
|
+
|
|
228
|
+
if client.connected?
|
|
229
|
+
record_connected(name, client, build_mcp_tools(client, name), spinner, logger)
|
|
230
|
+
else
|
|
231
|
+
record_failed_server(name, "connection failed")
|
|
232
|
+
logger.warn("MCP: '#{name}' failed to connect")
|
|
233
|
+
spinner.error("(connection failed)")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
rescue Timeout::Error
|
|
237
|
+
record_failed_server(name, "timed out after #{timeout}s")
|
|
238
|
+
logger.warn("MCP: '#{name}' timed out after #{timeout}s")
|
|
239
|
+
spinner.error("(timed out)")
|
|
240
|
+
rescue StandardError => e
|
|
241
|
+
msg = e.message
|
|
242
|
+
record_failed_server(name, msg)
|
|
243
|
+
logger.warn("MCP: '#{name}' error: #{msg}")
|
|
244
|
+
spinner.error("(#{msg})")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Wrap each remote tool definition in a RobotLab::Tool proxying to the client.
|
|
248
|
+
def build_mcp_tools(client, server_name)
|
|
249
|
+
client.list_tools.map do |tool_def|
|
|
250
|
+
tool_name = tool_def[:name]
|
|
251
|
+
RobotLab::Tool.create(
|
|
252
|
+
name: tool_name,
|
|
253
|
+
description: tool_def[:description],
|
|
254
|
+
parameters: tool_def[:inputSchema],
|
|
255
|
+
mcp: server_name
|
|
256
|
+
) { |args| client.call_tool(tool_name, args) }
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def record_connected(name, client, tools, spinner, logger)
|
|
261
|
+
tool_count = tools.size
|
|
262
|
+
|
|
263
|
+
@mutex.synchronize do
|
|
264
|
+
@connected_clients[name] = client
|
|
265
|
+
@server_tool_counts[name] = tool_count
|
|
266
|
+
add_tools_deduped(tools, name, logger)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
logger.info("MCP: '#{name}' connected (#{tool_count} tools)")
|
|
270
|
+
spinner.success("(#{tool_count} tools)")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def record_failed_server(name, error)
|
|
274
|
+
@mutex.synchronize { @failed_servers << { name: name, error: error } }
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Add tools to @connected_tools, skipping any whose name is already present.
|
|
278
|
+
# Logs a warning for each duplicate so the user knows which server wins.
|
|
279
|
+
# MUST be called inside @mutex.synchronize.
|
|
280
|
+
#
|
|
281
|
+
# @param new_tools [Array] tools to add
|
|
282
|
+
# @param server_name [String] name of the server providing these tools (for warning)
|
|
283
|
+
# @param logger [Logger] MCP logger
|
|
284
|
+
def add_tools_deduped(new_tools, server_name, logger)
|
|
285
|
+
new_tools.each do |tool|
|
|
286
|
+
tool_name = tool.respond_to?(:name) ? tool.name : tool.to_s
|
|
287
|
+
existing = @connected_tools.find do |t|
|
|
288
|
+
(t.respond_to?(:name) ? t.name : t.to_s) == tool_name
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
if existing
|
|
292
|
+
logger.warn("MCP: Tool '#{tool_name}' already registered from another server. " \
|
|
293
|
+
"Skipping duplicate from '#{server_name}'.")
|
|
294
|
+
else
|
|
295
|
+
@connected_tools << tool
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Extract timeout in seconds from server config.
|
|
301
|
+
# Config values >= 1000 are treated as milliseconds.
|
|
302
|
+
def server_timeout(server_config)
|
|
303
|
+
raw = server_config.is_a?(Hash) ? server_config[:timeout] : nil
|
|
304
|
+
return DEFAULT_TIMEOUT if raw.nil?
|
|
305
|
+
|
|
306
|
+
seconds = raw.to_f
|
|
307
|
+
seconds /= 1000.0 if seconds >= 1000 # values >= 1000 are milliseconds
|
|
308
|
+
# Honor the configured per-server timeout. DEFAULT_TIMEOUT is only the
|
|
309
|
+
# fallback when no timeout is set (handled above) — it must NOT cap a
|
|
310
|
+
# larger configured value, or slow-starting servers (npx/uvx cold starts)
|
|
311
|
+
# get dropped and their tools never reach the robot or the tool filter.
|
|
312
|
+
seconds.positive? ? seconds : DEFAULT_TIMEOUT
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_discovery.rb
|
|
4
|
+
#
|
|
5
|
+
# Determines which MCP servers should be activated for a given prompt.
|
|
6
|
+
# Two strategies: explicit (user directive) or all configured (default).
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
class MCPDiscovery
|
|
10
|
+
def initialize; end
|
|
11
|
+
|
|
12
|
+
# Discover relevant MCP servers for the current prompt.
|
|
13
|
+
# Priority: explicit --mcp-use list > all configured servers.
|
|
14
|
+
# --mcp-skip is always applied last.
|
|
15
|
+
#
|
|
16
|
+
# @param config the AIA configuration
|
|
17
|
+
# @return [Array<Hash>] relevant MCP server configs
|
|
18
|
+
def discover(config)
|
|
19
|
+
return [] if config.flags&.no_mcp
|
|
20
|
+
|
|
21
|
+
servers = select_servers(config)
|
|
22
|
+
apply_skip_filter(servers, config)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def select_servers(config)
|
|
28
|
+
return select_by_names(config.mcp_servers || [], Array(config.mcp_use)) if Array(config.mcp_use).any?
|
|
29
|
+
|
|
30
|
+
config.mcp_servers || []
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def apply_skip_filter(servers, config)
|
|
34
|
+
skip_list = Array(config.mcp_skip)
|
|
35
|
+
return servers if skip_list.empty?
|
|
36
|
+
|
|
37
|
+
servers.reject { |s| skip_list.include?(AIA::Utility.server_name(s)) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def select_by_names(all_servers, names)
|
|
41
|
+
all_servers.select { |s| names.include?(AIA::Utility.server_name(s)) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_grouper.rb
|
|
4
|
+
#
|
|
5
|
+
# Groups MCP servers into independent sets that can run concurrently.
|
|
6
|
+
# Servers with the same `group` value must run sequentially.
|
|
7
|
+
# Servers without a group are each independent.
|
|
8
|
+
|
|
9
|
+
module AIA
|
|
10
|
+
class MCPGrouper
|
|
11
|
+
# Group MCP servers into independent sets.
|
|
12
|
+
#
|
|
13
|
+
# @param servers [Array<Hash>] MCP server configs
|
|
14
|
+
# @return [Array<Array<Hash>>] groups of servers that can run concurrently
|
|
15
|
+
def group(servers)
|
|
16
|
+
return [] if servers.nil? || servers.empty?
|
|
17
|
+
|
|
18
|
+
independent = []
|
|
19
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
20
|
+
|
|
21
|
+
servers.each do |server|
|
|
22
|
+
group_name = server[:group] || server["group"]
|
|
23
|
+
if group_name
|
|
24
|
+
groups[group_name] << server
|
|
25
|
+
else
|
|
26
|
+
independent << [server] # Each independent server is its own group
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
independent + groups.values
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# lib/aia/mcp_server_config.rb
|
|
2
|
+
|
|
3
|
+
module AIA
|
|
4
|
+
# Normalized view of one MCP server hash. Server configs arrive with
|
|
5
|
+
# symbol or string keys, and with connection fields either nested under
|
|
6
|
+
# :transport or at the top level; this object owns all of that fallback
|
|
7
|
+
# logic so callers never touch the raw hash shape.
|
|
8
|
+
McpServerConfig = Data.define(:name, :command, :args, :env, :timeout_raw) do
|
|
9
|
+
def self.from_hash(server)
|
|
10
|
+
transport = fetch_key(server, :transport) || {}
|
|
11
|
+
new(
|
|
12
|
+
name: AIA::Utility.server_name(server),
|
|
13
|
+
command: fetch_key(transport, :command) || fetch_key(server, :command),
|
|
14
|
+
args: Array(fetch_key(transport, :args) || fetch_key(server, :args)),
|
|
15
|
+
env: fetch_key(transport, :env) || fetch_key(server, :env) || {},
|
|
16
|
+
timeout_raw: fetch_key(server, :timeout)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Read a hash entry by symbol or string key.
|
|
21
|
+
def self.fetch_key(hash, key) = hash[key] || hash[key.to_s]
|
|
22
|
+
|
|
23
|
+
# Timeout in milliseconds; raw values below 1000 are treated as seconds.
|
|
24
|
+
def timeout_ms(default: 8_000, cap: 30_000)
|
|
25
|
+
raw = (timeout_raw || default).to_i
|
|
26
|
+
ms = raw < 1000 ? raw * 1000 : raw
|
|
27
|
+
[ms, cap].min
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# lib/aia/mcp_utility.rb
|
|
4
|
+
#
|
|
5
|
+
# Pure MCP state-query methods mixed into AIA::Utility via `class << self include`.
|
|
6
|
+
# No display or formatting concerns — those live in Utility directly.
|
|
7
|
+
|
|
8
|
+
module AIA
|
|
9
|
+
module MCPUtility
|
|
10
|
+
def mcp_servers?
|
|
11
|
+
effective_mcp_server_names.any?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def mcp_server_names
|
|
15
|
+
connected = AIA.config&.connected_mcp_servers
|
|
16
|
+
return connected unless connected.nil?
|
|
17
|
+
return RubyLLM::MCP.clients.keys if defined?(RubyLLM::MCP) && RubyLLM::MCP.clients.any?
|
|
18
|
+
effective_mcp_server_names
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def connected_mcp_servers?
|
|
22
|
+
mcp_server_names.any?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def failed_mcp_servers
|
|
26
|
+
AIA.config&.failed_mcp_servers || []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# :reek:TooManyStatements -- server-name resolution applying no_mcp, use-list, and skip-list filters in precedence order
|
|
30
|
+
def effective_mcp_server_names
|
|
31
|
+
cfg = AIA.config
|
|
32
|
+
return [] if cfg&.flags&.no_mcp
|
|
33
|
+
|
|
34
|
+
servers = cfg&.mcp_servers || []
|
|
35
|
+
return [] if servers.empty?
|
|
36
|
+
|
|
37
|
+
names = servers.map { |s| server_name(s) }.compact
|
|
38
|
+
use_list = Array(cfg.mcp_use)
|
|
39
|
+
skip_list = Array(cfg.mcp_skip)
|
|
40
|
+
|
|
41
|
+
if use_list.any?
|
|
42
|
+
names.select { |n| use_list.include?(n) }
|
|
43
|
+
elsif skip_list.any?
|
|
44
|
+
names.reject { |n| skip_list.include?(n) }
|
|
45
|
+
else
|
|
46
|
+
names
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def server_name(s)
|
|
51
|
+
if s.is_a?(Hash)
|
|
52
|
+
s[:name] || s['name']
|
|
53
|
+
elsif s.respond_to?(:name)
|
|
54
|
+
s.name
|
|
55
|
+
else
|
|
56
|
+
s.to_s
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|