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 +4 -4
- data/CHANGELOG.md +27 -0
- data/CLAUDE.md +1 -1
- data/README.md +1 -1
- data/lib/legion/mcp/catalog_bridge.rb +1 -1
- data/lib/legion/mcp/context_compiler.rb +4 -4
- data/lib/legion/mcp/function_discovery.rb +119 -0
- data/lib/legion/mcp/pattern_compiler.rb +2 -2
- data/lib/legion/mcp/self_generate.rb +71 -27
- data/lib/legion/mcp/server.rb +36 -6
- data/lib/legion/mcp/settings.rb +59 -1
- data/lib/legion/mcp/tier_router.rb +2 -2
- data/lib/legion/mcp/version.rb +1 -1
- data/lib/legion/mcp.rb +1 -0
- metadata +2 -3
- data/lib/legion/mcp/capability_generator.rb +0 -114
- data/lib/legion/mcp/function_generator.rb +0 -158
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7d5c086abd567056b532b2e01df696e4fc025e1e709560b4260c8c81a8f921b
|
|
4
|
+
data.tar.gz: b00dd7b18bd76e8342d93e7422141bdb26210fa74dc7a638d45e0e6121b2d164
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
9
|
+
return [] unless defined?(Legion::MCP::Server)
|
|
10
10
|
|
|
11
|
-
Legion::MCP::Server
|
|
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,
|
|
25
|
+
return { success: true, gaps_found: 0, published: 0 } if gaps.empty?
|
|
18
26
|
|
|
19
|
-
top_gaps = gaps.sort_by { |g| -g[:priority] }.first(
|
|
27
|
+
top_gaps = gaps.sort_by { |g| -g[:priority] }.first(max_gaps_per_cycle)
|
|
20
28
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
published_count = 0
|
|
30
|
+
top_gaps.each do |gap|
|
|
31
|
+
published_count += 1 if publish_gap(gap)
|
|
24
32
|
end
|
|
25
33
|
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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 <
|
|
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 =
|
|
111
|
+
remaining = cooldown_seconds - (Time.now - last_cycle_at)
|
|
76
112
|
[remaining, 0].max.round(1)
|
|
77
113
|
end
|
|
78
114
|
|
|
79
|
-
def
|
|
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
|
-
@
|
|
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
|
data/lib/legion/mcp/server.rb
CHANGED
|
@@ -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 '
|
|
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
|
-
|
|
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(
|
|
170
|
+
ToolGovernance.filter_tools(tool_registry, identity)
|
|
145
171
|
else
|
|
146
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
data/lib/legion/mcp/settings.rb
CHANGED
|
@@ -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
|
|
105
|
+
return nil unless defined?(Legion::MCP::Server)
|
|
106
106
|
|
|
107
|
-
Legion::MCP::Server
|
|
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
|
data/lib/legion/mcp/version.rb
CHANGED
data/lib/legion/mcp.rb
CHANGED
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.
|
|
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/
|
|
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
|