legion-mcp 0.6.0 → 0.6.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19e316cc546a5fae512d371abccff4f2b6eef90d2902ee7df75a97eb5680dc01
4
- data.tar.gz: 0b9d4d42b3a3677e66d289454e5d496c0535f8d99e71c1a20a9a2d718c18305a
3
+ metadata.gz: e7d5c086abd567056b532b2e01df696e4fc025e1e709560b4260c8c81a8f921b
4
+ data.tar.gz: b00dd7b18bd76e8342d93e7422141bdb26210fa74dc7a638d45e0e6121b2d164
5
5
  SHA512:
6
- metadata.gz: c15a8d8c104f8fbbfeccbc50e6a58363a7989ca84e28c13d9b8199fe6ea700733e3f449cb619ecc4c5948ca947f9f9b756eda51a707705f765504145faa64543
7
- data.tar.gz: 3001f29ecd4a9da497c4ee5824086bdb7288a99db51202d017d031df38a0f987f4db0ba123cf61e527f1e961be5422307aac394001b55250c3a461514e20c466
6
+ metadata.gz: b06032b5477c79f163db706354ff02913ee3ffeffed4d036276c0acb186842452e0c2987275644bab9da0372392eb7442daba094a345a9bd7cc1ff0faefd92df
7
+ data.tar.gz: da7f527912b6b96b40b45623c2090715bb91c0b3cf448e97e7fd40cc3546a86644f72dc7d40a0a72b40949ce9c3f9f152132bc6333b23be5a687d5675bf57e27
data/CHANGELOG.md CHANGED
@@ -2,6 +2,33 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.2] - 2026-03-26
6
+
7
+ ### Fixed
8
+ - `deps_satisfied?` now strips leading `::` and rejects empty parts from dependency strings to avoid `NameError` on constants like `::Legion::MCP`
9
+ - `discover_and_register` prefers `Legion::Extensions.extensions` public accessor with ivar fallback
10
+ - `tool_registry` and `@tool_registry_lock` initialized eagerly at module level to eliminate thread-race on first access
11
+ - Removed `@tool_registry_lock ||=` guard from `register_tool`/`unregister_tool` (lock always present)
12
+ - Added explicit `require 'concurrent'` to `lib/legion/mcp.rb` to prevent `NameError: uninitialized constant Concurrent` in isolation
13
+
14
+ ### Changed
15
+ - Spec descriptions updated from `TOOL_CLASSES` to `tool_registry` / `Server.tool_registry` for accuracy
16
+
17
+ ## [0.6.1] - 2026-03-26
18
+
19
+ ### Changed
20
+ - Replace frozen TOOL_CLASSES with mutable tool_registry for dynamic tool registration
21
+ - Simplify self_generate to detect gaps and publish via AMQP (removed FunctionGenerator)
22
+ - Extract `runner_expose_opts` and `register_function` helpers from `build_tools_from_runner` to reduce cyclomatic complexity
23
+ - Split `Settings.defaults` into focused sub-methods to reduce method length
24
+
25
+ ### Added
26
+ - Codegen self-generate and MCP auto-expose settings defaults
27
+ - Function metadata auto-discovery for dynamic MCP tool registration
28
+
29
+ ### Removed
30
+ - `function_generator.rb` and `capability_generator.rb` (generation moved to lex-codegen)
31
+
5
32
  ## [0.6.0] - 2026-03-26
6
33
 
7
34
  ### Added
data/CLAUDE.md CHANGED
@@ -7,7 +7,7 @@
7
7
  Standalone gem providing the Model Context Protocol (MCP) server for LegionIO. Extracted from LegionIO to enable independent versioning and reuse. Includes semantic tool matching, observation pipeline, context compilation, tiered inference (Tier 0/1/2), and tool governance.
8
8
 
9
9
  **GitHub**: https://github.com/LegionIO/legion-mcp
10
- **Version**: 0.6.0
10
+ **Version**: 0.6.2
11
11
  **License**: Apache-2.0
12
12
  **Ruby**: >= 3.4
13
13
 
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  MCP (Model Context Protocol) server for the LegionIO framework. Provides semantic tool matching, observation pipeline, context compilation, and tiered behavioral intelligence (Tier 0/1/2 routing).
4
4
 
5
- **Version**: 0.6.0
5
+ **Version**: 0.6.1
6
6
 
7
7
  Extracted from [LegionIO](https://github.com/LegionIO/LegionIO) for independent versioning and reuse.
8
8
 
@@ -38,7 +38,7 @@ module Legion
38
38
  end
39
39
 
40
40
  def dynamic_tool_list
41
- static = Server::TOOL_CLASSES.map do |klass|
41
+ static = Server.tool_registry.map do |klass|
42
42
  { name: klass.tool_name, description: klass.description,
43
43
  input_schema: klass.input_schema, source: :builtin, klass: klass }
44
44
  end
@@ -62,7 +62,7 @@ module Legion
62
62
  end
63
63
  end
64
64
 
65
- # Returns tools for a specific category, filtered to only those present in TOOL_CLASSES.
65
+ # Returns tools for a specific category, filtered to only those present in Server.tool_registry.
66
66
  # @param category_sym [Symbol] one of the CATEGORIES keys
67
67
  # @return [Hash, nil] { category:, summary:, tools: [{ name:, description:, params: }] } or nil
68
68
  def category_tools(category_sym)
@@ -82,7 +82,7 @@ module Legion
82
82
 
83
83
  # Keyword-match intent against tool names and descriptions.
84
84
  # @param intent_string [String] natural language intent
85
- # @return [Class, nil] best matching tool CLASS from Server::TOOL_CLASSES or nil
85
+ # @return [Class, nil] best matching tool CLASS from Server.tool_registry or nil
86
86
  def match_tool(intent_string)
87
87
  scored = scored_tools(intent_string)
88
88
  return nil if scored.empty?
@@ -90,7 +90,7 @@ module Legion
90
90
  best = scored.max_by { |entry| entry[:score] }
91
91
  return nil if best[:score].zero?
92
92
 
93
- Server::TOOL_CLASSES.find { |klass| klass.tool_name == best[:name] }
93
+ Server.tool_registry.find { |klass| klass.tool_name == best[:name] }
94
94
  end
95
95
 
96
96
  # Returns top N keyword-matched tools ranked by score.
@@ -118,7 +118,7 @@ module Legion
118
118
  end
119
119
 
120
120
  def build_tool_index
121
- Server::TOOL_CLASSES.each_with_object({}) do |klass, idx|
121
+ Server.tool_registry.each_with_object({}) do |klass, idx|
122
122
  raw_schema = klass.input_schema
123
123
  schema = raw_schema.is_a?(Hash) ? raw_schema : raw_schema.to_h
124
124
  properties = schema[:properties] || {}
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module FunctionDiscovery
6
+ module_function
7
+
8
+ def discover_and_register
9
+ return unless defined?(Legion::Extensions)
10
+
11
+ extensions =
12
+ if Legion::Extensions.respond_to?(:extensions)
13
+ Legion::Extensions.extensions || []
14
+ else
15
+ Legion::Extensions.instance_variable_get(:@extensions) || []
16
+ end
17
+ extensions.each do |ext|
18
+ next unless ext.respond_to?(:runner_modules)
19
+
20
+ ext.runner_modules.each { |runner_mod| build_tools_from_runner(runner_mod) }
21
+ rescue StandardError => e
22
+ Legion::Logging.debug("FunctionDiscovery: skipping #{ext}: #{e.message}") if defined?(Legion::Logging)
23
+ end
24
+ end
25
+
26
+ def build_tools_from_runner(runner_module)
27
+ return unless runner_module.respond_to?(:settings) && runner_module.settings.is_a?(Hash)
28
+
29
+ functions = runner_module.settings[:functions]
30
+ return if functions.nil? || functions.empty?
31
+
32
+ opts = runner_expose_opts(runner_module)
33
+ functions.each { |func_name, meta| register_function(runner_module, func_name, meta, opts) }
34
+ end
35
+
36
+ def runner_expose_opts(runner_module)
37
+ class_expose = runner_module.respond_to?(:expose_as_mcp_tool) ? runner_module.expose_as_mcp_tool : nil
38
+ global_expose = defined?(Legion::Settings) ? (Legion::Settings.dig(:mcp, :auto_expose_runners) || false) : false
39
+ prefix = runner_module.respond_to?(:mcp_tool_prefix) ? runner_module.mcp_tool_prefix : nil
40
+ { class_expose: class_expose, global_expose: global_expose, prefix: prefix }
41
+ end
42
+
43
+ def register_function(runner_module, func_name, meta, opts)
44
+ return unless should_expose?(meta, opts[:class_expose], opts[:global_expose])
45
+ return unless deps_satisfied?(meta[:requires])
46
+
47
+ tool_class = build_tool_class(
48
+ name: derive_tool_name(func_name, opts[:prefix]),
49
+ description: meta[:desc] || "Auto-discovered: #{func_name}",
50
+ input_schema: meta[:options] || { properties: {} },
51
+ runner_module: runner_module,
52
+ function_name: func_name
53
+ )
54
+
55
+ Server.register_tool(tool_class)
56
+ end
57
+
58
+ def should_expose?(func_meta, class_level, global_default)
59
+ return func_meta[:expose] unless func_meta[:expose].nil?
60
+ return class_level unless class_level.nil?
61
+
62
+ global_default || false
63
+ end
64
+
65
+ def derive_tool_name(func_name, prefix)
66
+ base = prefix || 'legion.generated'
67
+ "#{base}.#{func_name}"
68
+ end
69
+
70
+ def deps_satisfied?(deps)
71
+ return true if deps.nil? || deps.empty?
72
+
73
+ deps.all? do |dep|
74
+ parts = dep.delete_prefix('::').split('::').reject(&:empty?)
75
+ current = Object
76
+ parts.all? do |part|
77
+ if current.const_defined?(part, false)
78
+ current = current.const_get(part, false)
79
+ true
80
+ else
81
+ false
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ def build_tool_class(name:, description:, input_schema:, runner_module:, function_name:)
88
+ runner_ref = runner_module
89
+ func_ref = function_name
90
+
91
+ Class.new(::MCP::Tool) do
92
+ tool_name name
93
+ description description
94
+ input_schema(input_schema)
95
+
96
+ define_singleton_method(:call) do |**params|
97
+ error = false
98
+
99
+ result =
100
+ if runner_ref.respond_to?(func_ref)
101
+ begin
102
+ runner_ref.public_send(func_ref, **params)
103
+ rescue StandardError => e
104
+ error = true
105
+ { error: e.message }
106
+ end
107
+ else
108
+ error = true
109
+ { error: "function #{func_ref} not found" }
110
+ end
111
+
112
+ text = defined?(Legion::JSON) ? Legion::JSON.dump(result) : result.to_s
113
+ ::MCP::Tool::Response.new([{ type: 'text', text: text }], error: error)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -6,9 +6,9 @@ module Legion
6
6
  module_function
7
7
 
8
8
  def compile_tool_definitions
9
- return [] unless defined?(Legion::MCP::Server::TOOL_CLASSES)
9
+ return [] unless defined?(Legion::MCP::Server)
10
10
 
11
- Legion::MCP::Server::TOOL_CLASSES.map do |klass|
11
+ Legion::MCP::Server.tool_registry.map do |klass|
12
12
  name = klass.respond_to?(:tool_name) ? klass.tool_name : klass.name
13
13
  desc = klass.respond_to?(:description) ? klass.description : ''
14
14
  params = extract_params(klass)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'digest'
4
+ require 'time'
4
5
 
5
6
  module Legion
6
7
  module MCP
@@ -10,41 +11,76 @@ module Legion
10
11
 
11
12
  module_function
12
13
 
14
+ def enabled?
15
+ return false unless defined?(Legion::Settings)
16
+
17
+ Legion::Settings.dig(:codegen, :self_generate, :enabled) == true
18
+ end
19
+
13
20
  def run_cycle
21
+ return { success: false, reason: :disabled } unless enabled?
14
22
  return { success: false, reason: :cooldown } if in_cooldown?
15
23
 
16
24
  gaps = GapDetector.detect_gaps
17
- return { success: true, gaps_found: 0, generated: 0 } if gaps.empty?
25
+ return { success: true, gaps_found: 0, published: 0 } if gaps.empty?
18
26
 
19
- top_gaps = gaps.sort_by { |g| -g[:priority] }.first(MAX_GAPS_PER_CYCLE)
27
+ top_gaps = gaps.sort_by { |g| -g[:priority] }.first(max_gaps_per_cycle)
20
28
 
21
- results = top_gaps.map do |gap|
22
- result = FunctionGenerator.generate_from_gap(gap)
23
- { gap: gap[:id], type: gap[:type], result: result }
29
+ published_count = 0
30
+ top_gaps.each do |gap|
31
+ published_count += 1 if publish_gap(gap)
24
32
  end
25
33
 
26
- record_cycle(results)
34
+ if published_count.zero?
35
+ reason = defined?(Legion::Transport::Messages::Dynamic) ? :publish_failed : :transport_unavailable
36
+ return {
37
+ success: false,
38
+ reason: reason,
39
+ gaps_found: gaps.size,
40
+ processed: top_gaps.size,
41
+ published: 0
42
+ }
43
+ end
27
44
 
28
- generated = results.count { |r| r[:result][:success] }
29
- failed = results.count { |r| !r[:result][:success] }
45
+ record_cycle(published_count)
30
46
 
31
47
  {
32
48
  success: true,
33
49
  gaps_found: gaps.size,
34
50
  processed: top_gaps.size,
35
- generated: generated,
36
- failed: failed,
37
- results: results
51
+ published: published_count
38
52
  }
39
53
  end
40
54
 
55
+ def publish_gap(gap)
56
+ return false unless defined?(Legion::Transport::Messages::Dynamic)
57
+
58
+ Legion::Transport::Messages::Dynamic.new(
59
+ function: 'codegen.gap.detected',
60
+ data: {
61
+ gap_id: gap[:id],
62
+ gap_type: gap[:type],
63
+ intent: gap[:intent] || gap[:intent_text],
64
+ occurrence_count: gap[:occurrences] || gap[:observation_count] || gap[:failure_count] || 1,
65
+ priority: gap[:priority],
66
+ metadata: gap[:metadata] || {},
67
+ detected_at: Time.now.iso8601
68
+ }
69
+ ).publish
70
+ true
71
+ rescue StandardError => e
72
+ Legion::Logging.warn("SelfGenerate#publish_gap failed: #{e.message}") if defined?(Legion::Logging)
73
+ false
74
+ end
75
+
41
76
  def status
42
77
  {
43
78
  last_cycle_at: last_cycle_at,
44
79
  total_cycles: cycle_count,
45
- total_generated: total_generated,
80
+ total_published: total_published,
46
81
  cooldown_remaining: cooldown_remaining,
47
- pending_gaps: GapDetector.detect_gaps.size
82
+ pending_gaps: GapDetector.detect_gaps.size,
83
+ enabled: enabled?
48
84
  }
49
85
  rescue StandardError => e
50
86
  { error: e.message }
@@ -54,7 +90,7 @@ module Legion
54
90
  mutex.synchronize do
55
91
  @last_cycle_at = nil
56
92
  @cycle_count = 0
57
- @total_generated = 0
93
+ @total_published = 0
58
94
  @cycle_history = []
59
95
  end
60
96
  end
@@ -66,27 +102,39 @@ module Legion
66
102
  def in_cooldown?
67
103
  return false unless last_cycle_at
68
104
 
69
- Time.now - last_cycle_at < COOLDOWN_SECONDS
105
+ Time.now - last_cycle_at < cooldown_seconds
70
106
  end
71
107
 
72
108
  def cooldown_remaining
73
109
  return 0 unless last_cycle_at
74
110
 
75
- remaining = COOLDOWN_SECONDS - (Time.now - last_cycle_at)
111
+ remaining = cooldown_seconds - (Time.now - last_cycle_at)
76
112
  [remaining, 0].max.round(1)
77
113
  end
78
114
 
79
- def record_cycle(results)
115
+ def total_published
116
+ mutex.synchronize { @total_published || 0 }
117
+ end
118
+
119
+ # private helpers
120
+
121
+ def max_gaps_per_cycle
122
+ val = Legion::Settings.dig(:codegen, :self_generate, :max_gaps_per_cycle) if defined?(Legion::Settings)
123
+ val || MAX_GAPS_PER_CYCLE
124
+ end
125
+
126
+ def cooldown_seconds
127
+ val = Legion::Settings.dig(:codegen, :self_generate, :cooldown_seconds) if defined?(Legion::Settings)
128
+ val || COOLDOWN_SECONDS
129
+ end
130
+
131
+ def record_cycle(published_count)
80
132
  mutex.synchronize do
81
133
  @last_cycle_at = Time.now
82
134
  @cycle_count = (@cycle_count || 0) + 1
83
- @total_generated = (@total_generated || 0) + results.count { |r| r[:result][:success] }
135
+ @total_published = (@total_published || 0) + published_count
84
136
  @cycle_history ||= []
85
- @cycle_history << {
86
- at: Time.now,
87
- results_count: results.size,
88
- generated: results.count { |r| r[:result][:success] }
89
- }
137
+ @cycle_history << { at: Time.now, published: published_count }
90
138
  @cycle_history.shift if @cycle_history.size > 50
91
139
  end
92
140
  end
@@ -99,10 +147,6 @@ module Legion
99
147
  mutex.synchronize { @cycle_count || 0 }
100
148
  end
101
149
 
102
- def total_generated
103
- mutex.synchronize { @total_generated || 0 }
104
- end
105
-
106
150
  def mutex
107
151
  @mutex ||= Mutex.new
108
152
  end
@@ -48,7 +48,7 @@ require_relative 'context_compiler'
48
48
  require_relative 'embedding_index'
49
49
  require_relative 'cold_start'
50
50
  require_relative 'gap_detector'
51
- require_relative 'function_generator'
51
+ require_relative 'function_discovery'
52
52
  require_relative 'self_generate'
53
53
  require_relative 'tools/do_action'
54
54
  require_relative 'tools/plan_action'
@@ -74,7 +74,7 @@ require_relative 'resources/extension_info'
74
74
  module Legion
75
75
  module MCP
76
76
  module Server
77
- TOOL_CLASSES = [
77
+ STATIC_TOOLS = [
78
78
  Tools::RunTask,
79
79
  Tools::DescribeRunner,
80
80
  Tools::ListTasks,
@@ -136,14 +136,40 @@ module Legion
136
136
  Tools::KnowledgeContext
137
137
  ].freeze
138
138
 
139
+ @tool_registry = Concurrent::Array.new(STATIC_TOOLS)
140
+ @tool_registry_lock = Mutex.new
141
+
139
142
  class << self
140
143
  include CatalogBridge
141
144
 
145
+ attr_reader :tool_registry
146
+
147
+ def register_tool(tool_class)
148
+ @tool_registry_lock.synchronize do
149
+ return if tool_registry.any? { |tc| tc.tool_name == tool_class.tool_name }
150
+
151
+ tool_registry << tool_class
152
+ reset_caches!
153
+ end
154
+ end
155
+
156
+ def unregister_tool(tool_name)
157
+ @tool_registry_lock.synchronize do
158
+ tool_registry.reject! { |tc| tc.tool_name == tool_name }
159
+ reset_caches!
160
+ end
161
+ end
162
+
163
+ def reset_caches!
164
+ ContextCompiler.reset! if defined?(ContextCompiler)
165
+ EmbeddingIndex.reset! if defined?(EmbeddingIndex) && EmbeddingIndex.respond_to?(:reset!)
166
+ end
167
+
142
168
  def build(identity: nil)
143
169
  tools = if ToolGovernance.governance_enabled?
144
- ToolGovernance.filter_tools(TOOL_CLASSES, identity)
170
+ ToolGovernance.filter_tools(tool_registry, identity)
145
171
  else
146
- TOOL_CLASSES
172
+ tool_registry
147
173
  end
148
174
 
149
175
  server = ::MCP::Server.new(
@@ -171,6 +197,10 @@ module Legion
171
197
  # Cold-start: load community patterns if store is still empty after hydration
172
198
  ColdStart.load_community_patterns if defined?(ColdStart)
173
199
 
200
+ # Discover and register runner functions before building the embedding index
201
+ # so all tools are present when embeddings are populated
202
+ FunctionDiscovery.discover_and_register if defined?(Legion::Extensions)
203
+
174
204
  # Populate embedding index for semantic tool matching (lazy — no-op if LLM unavailable)
175
205
  populate_embedding_index
176
206
 
@@ -219,10 +249,10 @@ module Legion
219
249
  end
220
250
 
221
251
  def build_filtered_tool_list(keywords: [])
222
- tool_names = TOOL_CLASSES.map { |tc| tc.respond_to?(:tool_name) ? tc.tool_name : tc.name }
252
+ tool_names = tool_registry.map { |tc| tc.respond_to?(:tool_name) ? tc.tool_name : tc.name }
223
253
  ranked = UsageFilter.ranked_tools(tool_names, keywords: keywords)
224
254
  ranked.filter_map do |name|
225
- TOOL_CLASSES.find do |tc|
255
+ tool_registry.find do |tc|
226
256
  (tc.respond_to?(:tool_name) ? tc.tool_name : tc.name) == name
227
257
  end
228
258
  end
@@ -11,7 +11,65 @@ module Legion
11
11
  overrides: {},
12
12
  tool_cache_ttl: 300,
13
13
  connect_timeout: 10,
14
- call_timeout: 30
14
+ call_timeout: 30,
15
+ codegen: { self_generate: self_generate_defaults },
16
+ mcp: { auto_expose_runners: false }
17
+ }
18
+ end
19
+
20
+ def self_generate_defaults
21
+ {
22
+ enabled: false,
23
+ cooldown_seconds: 300,
24
+ max_gaps_per_cycle: 5,
25
+ tier: self_generate_tier_defaults,
26
+ runner_method: { output_dir: '~/.legionio/generated/runners', namespace: 'Legion::Generated' },
27
+ full_extension: { output_dir: '~/.legionio/generated/extensions', auto_bundle: false },
28
+ validation: self_generate_validation_defaults,
29
+ approval: { required: false, auto_approve_confidence: 0.9, auto_approve_gap_types: [] },
30
+ hot_register: { mcp_tools: true, full_load_on_boot: true },
31
+ corroboration: self_generate_corroboration_defaults,
32
+ github: self_generate_github_defaults
33
+ }
34
+ end
35
+
36
+ def self_generate_tier_defaults
37
+ {
38
+ simple_max_occurrences: 10,
39
+ complex_min_occurrences: 11,
40
+ recurrence_window_seconds: 86_400
41
+ }
42
+ end
43
+
44
+ def self_generate_validation_defaults
45
+ {
46
+ syntax_check: true,
47
+ run_specs: true,
48
+ llm_review: true,
49
+ max_retries: 2,
50
+ quality_gate: { enabled: false, threshold: 0.8 }
51
+ }
52
+ end
53
+
54
+ def self_generate_corroboration_defaults
55
+ {
56
+ enabled: true,
57
+ min_agents: 2,
58
+ apollo_query_before_generate: true,
59
+ priority_boost_per_agent: 0.15
60
+ }
61
+ end
62
+
63
+ def self_generate_github_defaults
64
+ {
65
+ enabled: false,
66
+ auto_branch: true,
67
+ auto_pr: true,
68
+ auto_merge: false,
69
+ target_repo: nil,
70
+ target_branch: 'main',
71
+ pr_labels: %w[auto-generated needs-review],
72
+ adversarial_reviewers: 3
15
73
  }
16
74
  end
17
75
  end
@@ -102,9 +102,9 @@ module Legion
102
102
  end
103
103
 
104
104
  def find_tool_class(tool_name)
105
- return nil unless defined?(Legion::MCP::Server::TOOL_CLASSES)
105
+ return nil unless defined?(Legion::MCP::Server)
106
106
 
107
- Legion::MCP::Server::TOOL_CLASSES.find do |klass|
107
+ Legion::MCP::Server.tool_registry.find do |klass|
108
108
  klass.respond_to?(:tool_name) && klass.tool_name == tool_name
109
109
  end
110
110
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module MCP
5
- VERSION = '0.6.0'
5
+ VERSION = '0.6.2'
6
6
  end
7
7
  end
data/lib/legion/mcp.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'concurrent'
3
4
  require 'mcp'
4
5
  require 'legion/json'
5
6
  require_relative 'mcp/version'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -120,7 +120,6 @@ files:
120
120
  - legion-mcp.gemspec
121
121
  - lib/legion/mcp.rb
122
122
  - lib/legion/mcp/auth.rb
123
- - lib/legion/mcp/capability_generator.rb
124
123
  - lib/legion/mcp/catalog_bridge.rb
125
124
  - lib/legion/mcp/client.rb
126
125
  - lib/legion/mcp/client/connection.rb
@@ -130,7 +129,7 @@ files:
130
129
  - lib/legion/mcp/context_compiler.rb
131
130
  - lib/legion/mcp/context_guard.rb
132
131
  - lib/legion/mcp/embedding_index.rb
133
- - lib/legion/mcp/function_generator.rb
132
+ - lib/legion/mcp/function_discovery.rb
134
133
  - lib/legion/mcp/gap_detector.rb
135
134
  - lib/legion/mcp/observer.rb
136
135
  - lib/legion/mcp/override_broadcast.rb
@@ -1,114 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module MCP
5
- module CapabilityGenerator
6
- module_function
7
-
8
- def generate_from_gap(gap)
9
- name = infer_name(gap)
10
- description = infer_description(gap)
11
-
12
- proposal = {
13
- name: name,
14
- description: description,
15
- source_gap: gap,
16
- runner_code: nil,
17
- spec_code: nil,
18
- confidence: :sandbox,
19
- generated_at: Time.now
20
- }
21
-
22
- if llm_available?
23
- proposal[:runner_code] = generate_runner(name, description, gap)
24
- proposal[:spec_code] = generate_spec(name, description)
25
- end
26
-
27
- proposal
28
- rescue StandardError => e
29
- Legion::Logging.warn("CapabilityGenerator#generate_from_gap failed: #{e.message}") if defined?(Legion::Logging)
30
- { error: e.message, source_gap: gap }
31
- end
32
-
33
- def validate(runner_code:, spec_code:) # rubocop:disable Lint/UnusedMethodArgument
34
- result = { syntax_valid: false, eval_score: nil }
35
-
36
- result[:syntax_valid] = syntax_valid?(runner_code) if runner_code
37
-
38
- if runner_code && defined?(Legion::Extensions::Eval::Client)
39
- begin
40
- client = Legion::Extensions::Eval::Client.new
41
- eval_result = client.evaluate(code: runner_code, criteria: 'code_quality')
42
- result[:eval_score] = eval_result[:score] if eval_result[:success]
43
- rescue StandardError => e
44
- Legion::Logging.warn("CapabilityGenerator#validate eval failed: #{e.message}") if defined?(Legion::Logging)
45
- nil
46
- end
47
- end
48
-
49
- result
50
- end
51
-
52
- def infer_name(gap)
53
- case gap[:type]
54
- when :frequent_intent
55
- gap[:sample_intents].first.to_s.gsub(/\s+/, '_').downcase.slice(0, 30)
56
- when :repeated_chain
57
- gap[:chain].join('_then_').slice(0, 30)
58
- else
59
- "generated_#{Time.now.to_i}"
60
- end
61
- end
62
-
63
- def infer_description(gap)
64
- case gap[:type]
65
- when :frequent_intent
66
- "Auto-generated from #{gap[:count]} observed intents: #{gap[:sample_intents].first(3).join(', ')}"
67
- when :repeated_chain
68
- "Auto-generated from #{gap[:count]} observed sequences: #{gap[:chain].join(' -> ')}"
69
- else
70
- 'Auto-generated capability'
71
- end
72
- end
73
-
74
- def generate_runner(name, description, _gap)
75
- return nil unless llm_available?
76
-
77
- prompt = "Generate a Ruby module for a LegionIO runner named '#{name}'. " \
78
- "Description: #{description}. " \
79
- 'Follow the pattern: module with module_function methods returning hashes. ' \
80
- 'Include proper error handling. Return ONLY the Ruby code.'
81
-
82
- Legion::LLM.ask(prompt, caller: { extension: 'legion-mcp', operation: 'capability_gen', phase: 'runner' })
83
- rescue StandardError => e
84
- Legion::Logging.warn("CapabilityGenerator#generate_runner failed: #{e.message}") if defined?(Legion::Logging)
85
- nil
86
- end
87
-
88
- def generate_spec(name, description)
89
- return nil unless llm_available?
90
-
91
- prompt = "Generate RSpec tests for a Ruby module named '#{name}'. " \
92
- "Description: #{description}. " \
93
- 'Use described_class pattern. Return ONLY the Ruby code.'
94
-
95
- Legion::LLM.ask(prompt, caller: { extension: 'legion-mcp', operation: 'capability_gen', phase: 'spec' })
96
- rescue StandardError => e
97
- Legion::Logging.warn("CapabilityGenerator#generate_spec failed: #{e.message}") if defined?(Legion::Logging)
98
- nil
99
- end
100
-
101
- def syntax_valid?(code)
102
- RubyVM::InstructionSequence.compile(code)
103
- true
104
- rescue SyntaxError => e
105
- Legion::Logging.debug("CapabilityGenerator#syntax_valid? syntax error: #{e.message}") if defined?(Legion::Logging)
106
- false
107
- end
108
-
109
- def llm_available?
110
- defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
111
- end
112
- end
113
- end
114
- end
@@ -1,158 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'digest'
4
-
5
- module Legion
6
- module MCP
7
- module FunctionGenerator
8
- MAX_GENERATION_ATTEMPTS = 3
9
- GENERATION_TIMEOUT = 60
10
-
11
- module_function
12
-
13
- def generate_from_gap(gap)
14
- case gap[:type]
15
- when :unmatched_intent
16
- generate_tool_for_intent(intent: gap[:intent])
17
- when :high_failure_tool
18
- generate_fix_for_tool(tool_name: gap[:tool_name], last_error: gap[:last_error])
19
- when :stale_candidate
20
- generate_tool_for_candidate(intent_text: gap[:intent_text], tool_chain: gap[:tool_chain])
21
- else
22
- { success: false, reason: :unknown_gap_type }
23
- end
24
- rescue StandardError => e
25
- { success: false, reason: :generation_failed, error: e.message }
26
- end
27
-
28
- def generate_tool_for_intent(intent:)
29
- return { success: false, reason: :llm_not_available } unless llm_available?
30
-
31
- spec = generate_tool_spec(intent: intent)
32
- return spec unless spec[:success]
33
-
34
- validate_spec(spec[:tool_spec])
35
- end
36
-
37
- def generate_fix_for_tool(tool_name:, last_error:)
38
- return { success: false, reason: :llm_not_available } unless llm_available?
39
-
40
- prompt = build_fix_prompt(tool_name: tool_name, error: last_error)
41
- result = llm_ask(prompt)
42
- return { success: false, reason: :llm_failed } unless result
43
-
44
- {
45
- success: true,
46
- type: :fix_suggestion,
47
- tool_name: tool_name,
48
- suggestion: result,
49
- requires_review: true
50
- }
51
- end
52
-
53
- def generate_tool_for_candidate(intent_text:, tool_chain:)
54
- return { success: false, reason: :llm_not_available } unless llm_available?
55
-
56
- spec = generate_tool_spec(intent: intent_text, existing_chain: tool_chain)
57
- return spec unless spec[:success]
58
-
59
- register_generated_pattern(spec[:tool_spec], intent_text)
60
-
61
- spec
62
- end
63
-
64
- def generate_tool_spec(intent:, existing_chain: nil)
65
- prompt = build_generation_prompt(intent: intent, existing_chain: existing_chain)
66
- result = llm_ask(prompt)
67
- return { success: false, reason: :llm_failed } unless result
68
-
69
- parsed = parse_tool_spec(result)
70
- return { success: false, reason: :parse_failed, raw: result } unless parsed
71
-
72
- { success: true, tool_spec: parsed }
73
- end
74
-
75
- def validate_spec(spec)
76
- errors = []
77
- errors << 'missing name' unless spec[:name]&.length&.positive?
78
- errors << 'missing description' unless spec[:description]&.length&.positive?
79
- errors << 'missing runner_function' unless spec[:runner_function]&.length&.positive?
80
-
81
- if errors.empty?
82
- { success: true, tool_spec: spec, valid: true }
83
- else
84
- { success: false, reason: :invalid_spec, errors: errors, tool_spec: spec }
85
- end
86
- end
87
-
88
- def llm_available?
89
- !!(defined?(Legion::LLM) && Legion::LLM.respond_to?(:chat))
90
- end
91
-
92
- def llm_ask(prompt)
93
- return nil unless llm_available?
94
-
95
- response = Legion::LLM.chat(
96
- message: prompt,
97
- caller: { source: 'legion-mcp', component: 'function_generator' }
98
- )
99
- response&.content
100
- rescue StandardError => e
101
- Legion::Logging.warn("FunctionGenerator LLM call failed: #{e.message}") if defined?(Legion::Logging)
102
- nil
103
- end
104
-
105
- def build_generation_prompt(intent:, existing_chain: nil)
106
- chain_context = existing_chain ? "\nExisting tool chain that partially handles this: #{existing_chain.inspect}" : ''
107
-
108
- <<~PROMPT
109
- Generate a tool specification for a LegionIO MCP tool that handles this user intent:
110
- "#{intent}"
111
- #{chain_context}
112
- Respond with ONLY a JSON object (no markdown, no explanation):
113
- {
114
- "name": "legion.tool_name",
115
- "description": "What this tool does",
116
- "runner_function": "extension_name/runner_name/method_name",
117
- "parameters": [{"name": "param1", "type": "string", "required": true, "description": "..."}],
118
- "category": "one of: query, action, analysis, utility"
119
- }
120
- PROMPT
121
- end
122
-
123
- def build_fix_prompt(tool_name:, error:)
124
- <<~PROMPT
125
- The MCP tool "#{tool_name}" has a high failure rate. Last error: #{error}
126
- Suggest a fix or replacement approach. Respond concisely (2-3 sentences max).
127
- PROMPT
128
- end
129
-
130
- def parse_tool_spec(raw)
131
- json_match = raw.match(/\{[\s\S]*\}/)
132
- return nil unless json_match
133
-
134
- parsed = ::JSON.parse(json_match[0], symbolize_names: true)
135
- return nil unless parsed.is_a?(Hash) && parsed[:name]
136
-
137
- parsed
138
- rescue ::JSON::ParserError
139
- nil
140
- end
141
-
142
- def register_generated_pattern(spec, intent_text)
143
- return unless defined?(PatternStore)
144
-
145
- normalized = intent_text.to_s.strip.downcase.gsub(/\s+/, ' ')
146
- intent_hash = Digest::SHA256.hexdigest(normalized)
147
-
148
- PatternStore.promote_candidate(
149
- intent_hash: intent_hash,
150
- tool_chain: [spec[:runner_function] || spec[:name]],
151
- intent_text: intent_text
152
- )
153
- rescue StandardError => e
154
- Legion::Logging.warn("register_generated_pattern failed: #{e.message}") if defined?(Legion::Logging)
155
- end
156
- end
157
- end
158
- end