legion-mcp 0.6.6 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +37 -0
- data/CHANGELOG.md +25 -0
- data/Gemfile +1 -0
- data/lib/legion/mcp/catalog_bridge.rb +4 -0
- data/lib/legion/mcp/catalog_dispatcher.rb +91 -0
- data/lib/legion/mcp/context_compiler.rb +31 -0
- data/lib/legion/mcp/deferred_registry.rb +71 -0
- data/lib/legion/mcp/dynamic_injector.rb +69 -0
- data/lib/legion/mcp/server.rb +26 -12
- data/lib/legion/mcp/settings.rb +10 -1
- data/lib/legion/mcp/state_tracker.rb +130 -0
- data/lib/legion/mcp/structural_index.rb +134 -0
- data/lib/legion/mcp/tool_quality.rb +150 -0
- data/lib/legion/mcp/tools/discover_tools.rb +34 -9
- data/lib/legion/mcp/tools/search_sessions.rb +100 -0
- data/lib/legion/mcp/tools/state_diff.rb +53 -0
- data/lib/legion/mcp/tools/structural_index.rb +57 -0
- data/lib/legion/mcp/tools/tool_audit.rb +51 -0
- data/lib/legion/mcp/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ee9fe829857bd0a2546ea95daf827d33fe60084094e665a78f7fe78742064978
|
|
4
|
+
data.tar.gz: cc57fd55347d9a541b382d7ca8a92d4a2d9094e674142e4336cfaadf46f4071c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 15839e1d0bda42420e4734eef4a550a8b8b163453ec68a5eaf75aeb33b1d931625a5da8b30449db7627d2965ad7306d2a2c00633f559895ee442da35e5d5cee4
|
|
7
|
+
data.tar.gz: 77b1a31f1c5a24bc4c36b537500dfe849f9ff413a14de994032551d3161e52dee33729bd292ebb1a69fbe2625eeda682c985aa0c3937ed9d97ea30da7d0049d9
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# legion-mcp Agent Notes
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
`legion-mcp` is the standalone MCP server gem for Legion. It owns tool/resource registration, tier routing, tool governance, semantic matching, and observation/pattern promotion.
|
|
6
|
+
|
|
7
|
+
## Fast Start
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle install
|
|
11
|
+
bundle exec rspec
|
|
12
|
+
bundle exec rubocop -A
|
|
13
|
+
bundle exec rubocop
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Primary Entry Points
|
|
17
|
+
|
|
18
|
+
- `lib/legion/mcp.rb`
|
|
19
|
+
- `lib/legion/mcp/server.rb`
|
|
20
|
+
- `lib/legion/mcp/context_compiler.rb`
|
|
21
|
+
- `lib/legion/mcp/tier_router.rb`
|
|
22
|
+
- `lib/legion/mcp/pattern_store.rb`
|
|
23
|
+
- `lib/legion/mcp/observer.rb`
|
|
24
|
+
- `lib/legion/mcp/tools/`
|
|
25
|
+
|
|
26
|
+
## Guardrails
|
|
27
|
+
|
|
28
|
+
- Keep optional dependencies guarded (`legion-cache`, `legion-llm`, `Data::Local`); this gem must degrade cleanly.
|
|
29
|
+
- Tier confidence behavior is core contract: Tier 0 (>= 0.8), Tier 1 (0.6-0.8), Tier 2 (< 0.6).
|
|
30
|
+
- Pattern storage failures must not block tool execution.
|
|
31
|
+
- Tool registration remains centralized through server builder/registry; avoid ad hoc registration paths.
|
|
32
|
+
- Preserve governance checks and audit trails for tool invocation.
|
|
33
|
+
|
|
34
|
+
## Validation
|
|
35
|
+
|
|
36
|
+
- Run specs for changed tools/router/compiler paths.
|
|
37
|
+
- Before handoff, run `bundle exec rspec`, `bundle exec rubocop -A`, then `bundle exec rubocop`.
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.0] - 2026-03-31
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `DeferredRegistry` module for deferred tool loading — tools not in the always-loaded set return name and description only (no `inputSchema`) in `tools/list`, reducing token footprint by ~75% for standard MCP clients (closes #19)
|
|
9
|
+
- `legion.tools` now accepts `tool_names` (array) + `schema: true` parameters to load full JSON schemas for specific deferred tools on demand
|
|
10
|
+
- `Settings.deferred_loading_defaults` — configurable `enabled` (default true) and `always_loaded` (custom tool names merged with built-in defaults)
|
|
11
|
+
- 13 always-loaded tools: `legion.do`, `legion.tools`, `legion.run_task`, `legion.list_tasks`, `legion.get_task`, `legion.get_status`, `legion.describe_runner`, `legion.plan_action`, `legion.query_knowledge`, `legion.knowledge_context`, `legion.knowledge_health`, `legion.absorb`, `legion.get_task_logs`
|
|
12
|
+
|
|
13
|
+
- `CatalogDispatcher` module — thin dispatch layer routing MCP tool calls through `Legion::Ingress` for RBAC, audit, and sandbox enforcement; auto-generates tool classes from `Catalog::Registry` entries (closes #20)
|
|
14
|
+
- `DynamicInjector` module — context-aware tool injection/removal using `ContextCompiler.match_tools`; sends `notifications/tools/list_changed` when active tool set changes based on conversation context
|
|
15
|
+
- `CatalogBridge.register_catalog_tools` — auto-generates and registers catalog-sourced tools through `CatalogDispatcher` at server boot
|
|
16
|
+
- `Settings.dynamic_tools_defaults` — configurable `enabled` (default false) and `max_injected` (default 10)
|
|
17
|
+
- `StructuralIndex` module — precomputed static index of all extensions, runners, actors, and tools with JSON cache at `~/.legionio/cache/structural_index.json`; supports filtering by extension name or type (closes #18)
|
|
18
|
+
- `legion.structural_index` MCP tool (61st tool) — query the structural index with optional `extension`, `type`, and `refresh` parameters
|
|
19
|
+
- `ToolQuality` module — docstring quality gate (min description length, param descriptions), category resolution across `CATEGORIES` and `EXPANDED_CATEGORIES`, reads/writes capability matrix, and audit summary (closes #17)
|
|
20
|
+
- `legion.tool_audit` MCP tool (62nd tool) — audit all registered tools with modes: `summary` (default), `matrix` (capability matrix), `issues` (quality warnings only)
|
|
21
|
+
- `ContextCompiler::CATEGORIES` expanded from 9 to 16 categories: added `knowledge`, `mesh`, `mind_growth`, `prompts`, `datasets`, `evals`, `meta` — all 62 tools now have category assignments
|
|
22
|
+
- `StateTracker` module — in-memory state snapshots with timestamps and delta diff computation; tracks tool count, observer stats, pattern count, and extension count (closes #16)
|
|
23
|
+
- `legion.state_diff` MCP tool (63rd tool) — return only changed system state since a given timestamp; supports `snapshot: true` to take a baseline and `since:` for delta polling
|
|
24
|
+
- `legion.search_sessions` MCP tool (64th tool) — search across past conversation sessions by keyword or topic with relevance-sorted results and context snippets (closes #15)
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
- `Server.build` now installs a custom `tools/list` handler via `install_deferred_tools_list_handler` for mcp gem 0.10 compatibility (replaces removed `tools_list_handler` block API)
|
|
28
|
+
- `Server.build` now calls `register_catalog_tools` to auto-generate Ingress-dispatched tool classes from Catalog entries
|
|
29
|
+
|
|
5
30
|
## [0.6.6] - 2026-03-28
|
|
6
31
|
|
|
7
32
|
### Added
|
data/Gemfile
CHANGED
|
@@ -37,6 +37,10 @@ module Legion
|
|
|
37
37
|
{ status: :error, error: e.message, source: :catalog }
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def register_catalog_tools
|
|
41
|
+
CatalogDispatcher.generate_tools_from_catalog.each { |tc| Server.register_tool(tc) }
|
|
42
|
+
end
|
|
43
|
+
|
|
40
44
|
def dynamic_tool_list
|
|
41
45
|
static = Server.tool_registry.map do |klass|
|
|
42
46
|
{ name: klass.tool_name, description: klass.description,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module CatalogDispatcher
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def dispatch(runner_class:, function:, params:, source: :mcp)
|
|
9
|
+
return nil unless defined?(Legion::Ingress)
|
|
10
|
+
|
|
11
|
+
Legion::Ingress.run(
|
|
12
|
+
payload: params,
|
|
13
|
+
runner_class: runner_class,
|
|
14
|
+
function: function.to_sym,
|
|
15
|
+
source: source,
|
|
16
|
+
check_subtask: true,
|
|
17
|
+
generate_task: true
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build_tool_class(entry)
|
|
22
|
+
runner_class_str = entry[:runner_class]
|
|
23
|
+
function_name = entry[:function]
|
|
24
|
+
tool_name_val = entry[:tool_name]
|
|
25
|
+
desc = entry[:description]
|
|
26
|
+
schema = entry[:input_schema] || { properties: {} }
|
|
27
|
+
category = entry[:category]
|
|
28
|
+
tier = entry[:tier]
|
|
29
|
+
|
|
30
|
+
klass = Class.new(::MCP::Tool) do
|
|
31
|
+
tool_name tool_name_val
|
|
32
|
+
description desc
|
|
33
|
+
input_schema(schema)
|
|
34
|
+
define_singleton_method(:mcp_category) { category }
|
|
35
|
+
define_singleton_method(:mcp_tier) { tier }
|
|
36
|
+
define_singleton_method(:catalog_entry) { true }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
wire_dispatch(klass, runner_class_str, function_name)
|
|
40
|
+
klass
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wire_dispatch(klass, runner_class_str, function_name)
|
|
44
|
+
klass.define_singleton_method(:call) do |**params|
|
|
45
|
+
result = CatalogDispatcher.dispatch(
|
|
46
|
+
runner_class: runner_class_str,
|
|
47
|
+
function: function_name,
|
|
48
|
+
params: params
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if result.nil?
|
|
52
|
+
text = Legion::JSON.dump({ error: 'Ingress not available' })
|
|
53
|
+
::MCP::Tool::Response.new([{ type: 'text', text: text }], error: true)
|
|
54
|
+
else
|
|
55
|
+
text = defined?(Legion::JSON) ? Legion::JSON.dump(result) : result.to_s
|
|
56
|
+
::MCP::Tool::Response.new([{ type: 'text', text: text }])
|
|
57
|
+
end
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
Legion::Logging.warn("CatalogDispatcher: #{function_name} failed: #{e.message}") if defined?(Legion::Logging)
|
|
60
|
+
text = Legion::JSON.dump({ error: e.message })
|
|
61
|
+
::MCP::Tool::Response.new([{ type: 'text', text: text }], error: true)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def generate_tools_from_catalog
|
|
66
|
+
return [] unless defined?(Legion::Extensions::Catalog::Registry)
|
|
67
|
+
return [] unless Legion::Extensions::Catalog::Registry.respond_to?(:for_mcp)
|
|
68
|
+
|
|
69
|
+
Legion::Extensions::Catalog::Registry.for_mcp.filter_map do |cap|
|
|
70
|
+
build_tool_class(
|
|
71
|
+
runner_class: resolve_runner_class(cap),
|
|
72
|
+
function: cap.function,
|
|
73
|
+
tool_name: cap.respond_to?(:mcp_name) ? cap.mcp_name : "legion.catalog.#{cap.function}",
|
|
74
|
+
description: cap.respond_to?(:description) ? cap.description : "Auto-generated: #{cap.function}",
|
|
75
|
+
input_schema: cap.respond_to?(:input_schema) ? cap.input_schema : { properties: {} },
|
|
76
|
+
category: cap.respond_to?(:category) ? cap.category : nil,
|
|
77
|
+
tier: cap.respond_to?(:tier) ? cap.tier : nil
|
|
78
|
+
)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
Legion::Logging.debug("CatalogDispatcher: skipping #{cap}: #{e.message}") if defined?(Legion::Logging)
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolve_runner_class(cap)
|
|
86
|
+
segments = cap.extension.delete_prefix('lex-').split('-')
|
|
87
|
+
(%w[Legion Extensions] + segments.map(&:capitalize) + ['Runners', cap.runner]).join('::')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -43,6 +43,37 @@ module Legion
|
|
|
43
43
|
describe: {
|
|
44
44
|
tools: %w[legion.describe_runner],
|
|
45
45
|
summary: 'Inspect a specific runner function - parameters, return type, metadata.'
|
|
46
|
+
},
|
|
47
|
+
knowledge: {
|
|
48
|
+
tools: %w[legion.query_knowledge legion.knowledge_health legion.knowledge_context legion.absorb],
|
|
49
|
+
summary: 'Knowledge base operations - query, health, context retrieval, content absorption.'
|
|
50
|
+
},
|
|
51
|
+
mesh: {
|
|
52
|
+
tools: %w[legion.ask_peer legion.list_peers legion.notify_peer legion.broadcast_peers
|
|
53
|
+
legion.mesh_status],
|
|
54
|
+
summary: 'Agent mesh communication - peer queries, notifications, broadcasts, and topology.'
|
|
55
|
+
},
|
|
56
|
+
mind_growth: {
|
|
57
|
+
tools: %w[legion.mind_growth_status legion.mind_growth_propose legion.mind_growth_approve
|
|
58
|
+
legion.mind_growth_build_queue legion.mind_growth_cognitive_profile
|
|
59
|
+
legion.mind_growth_health],
|
|
60
|
+
summary: 'Cognitive growth - proposals, approvals, build queue, profiling, fitness scores.'
|
|
61
|
+
},
|
|
62
|
+
prompts: {
|
|
63
|
+
tools: %w[legion.prompt_list legion.prompt_show legion.prompt_run],
|
|
64
|
+
summary: 'Prompt template management - list, view, and render prompt templates.'
|
|
65
|
+
},
|
|
66
|
+
datasets: {
|
|
67
|
+
tools: %w[legion.dataset_list legion.dataset_show legion.experiment_results],
|
|
68
|
+
summary: 'Dataset and experiment browsing - list datasets, view rows, compare results.'
|
|
69
|
+
},
|
|
70
|
+
evals: {
|
|
71
|
+
tools: %w[legion.eval_list legion.eval_run legion.eval_results],
|
|
72
|
+
summary: 'Evaluation management - list evaluators, run evaluations, view results.'
|
|
73
|
+
},
|
|
74
|
+
meta: {
|
|
75
|
+
tools: %w[legion.do legion.tools legion.plan_action legion.structural_index],
|
|
76
|
+
summary: 'Meta-tools - natural language routing, tool discovery, planning, structural index.'
|
|
46
77
|
}
|
|
47
78
|
}.freeze
|
|
48
79
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module DeferredRegistry
|
|
6
|
+
# Tools that are ALWAYS fully loaded (never deferred).
|
|
7
|
+
# These are high-frequency entry points or meta-tools.
|
|
8
|
+
ALWAYS_LOADED = %w[
|
|
9
|
+
legion.do
|
|
10
|
+
legion.tools
|
|
11
|
+
legion.run_task
|
|
12
|
+
legion.list_tasks
|
|
13
|
+
legion.get_task
|
|
14
|
+
legion.get_status
|
|
15
|
+
legion.describe_runner
|
|
16
|
+
legion.plan_action
|
|
17
|
+
legion.query_knowledge
|
|
18
|
+
legion.knowledge_context
|
|
19
|
+
legion.knowledge_health
|
|
20
|
+
legion.absorb
|
|
21
|
+
legion.get_task_logs
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def enabled?
|
|
27
|
+
setting = Legion::Settings.dig(:mcp, :deferred_loading, :enabled)
|
|
28
|
+
setting.nil? || setting
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def always_loaded_tools
|
|
32
|
+
custom = Legion::Settings.dig(:mcp, :deferred_loading, :always_loaded)
|
|
33
|
+
custom.is_a?(Array) ? (ALWAYS_LOADED | custom) : ALWAYS_LOADED
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def deferred?(tool_class)
|
|
37
|
+
return false unless enabled?
|
|
38
|
+
|
|
39
|
+
name = tool_class.respond_to?(:tool_name) ? tool_class.tool_name : tool_class.name
|
|
40
|
+
!always_loaded_tools.include?(name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def deferred_entry(tool_class)
|
|
44
|
+
{ name: tool_class.tool_name, description: tool_class.description }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def full_entry(tool_class)
|
|
48
|
+
tool_class.to_h
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def build_tools_list(tool_classes)
|
|
52
|
+
tool_classes.map do |tc|
|
|
53
|
+
if deferred?(tc)
|
|
54
|
+
deferred_entry(tc)
|
|
55
|
+
else
|
|
56
|
+
full_entry(tc)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def resolve_schemas(tool_names, tool_classes)
|
|
62
|
+
tool_names.filter_map do |name|
|
|
63
|
+
tc = tool_classes.find { |klass| klass.tool_name == name }
|
|
64
|
+
next unless tc
|
|
65
|
+
|
|
66
|
+
tc.to_h
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module DynamicInjector
|
|
6
|
+
MAX_INJECTED = 10
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def enabled?
|
|
11
|
+
Legion::Settings.dig(:mcp, :dynamic_tools, :enabled) == true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def max_injected
|
|
15
|
+
Legion::Settings.dig(:mcp, :dynamic_tools, :max_injected) || MAX_INJECTED
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def context_tools(intent_string)
|
|
19
|
+
return [] unless enabled?
|
|
20
|
+
return [] if intent_string.nil? || intent_string.strip.empty?
|
|
21
|
+
|
|
22
|
+
matches = ContextCompiler.match_tools(intent_string, limit: max_injected)
|
|
23
|
+
return [] if matches.empty?
|
|
24
|
+
|
|
25
|
+
always = DeferredRegistry.always_loaded_tools
|
|
26
|
+
matches.filter_map do |match|
|
|
27
|
+
next if always.include?(match[:name])
|
|
28
|
+
next if match[:score] <= 0
|
|
29
|
+
|
|
30
|
+
Server.tool_registry.find { |tc| tc.tool_name == match[:name] }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def active_tool_set(intent_string)
|
|
35
|
+
always = always_loaded_classes
|
|
36
|
+
injected = context_tools(intent_string)
|
|
37
|
+
(always + injected).uniq(&:tool_name)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def always_loaded_classes
|
|
41
|
+
names = DeferredRegistry.always_loaded_tools
|
|
42
|
+
Server.tool_registry.select { |tc| names.include?(tc.tool_name) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tools_changed?(previous_names, current_names)
|
|
46
|
+
previous_names.sort != current_names.sort
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def notify_if_changed(server, previous_names, current_names)
|
|
50
|
+
return unless tools_changed?(previous_names, current_names)
|
|
51
|
+
return unless server.respond_to?(:notify_tools_list_changed)
|
|
52
|
+
|
|
53
|
+
server.notify_tools_list_changed
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
Legion::Logging.debug("DynamicInjector: notify failed: #{e.message}") if defined?(Legion::Logging)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def inject_for_context(server, intent_string, previous_names: [])
|
|
59
|
+
return previous_names unless enabled?
|
|
60
|
+
|
|
61
|
+
tools = active_tool_set(intent_string)
|
|
62
|
+
current_names = tools.map(&:tool_name)
|
|
63
|
+
|
|
64
|
+
notify_if_changed(server, previous_names, current_names)
|
|
65
|
+
current_names
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/legion/mcp/server.rb
CHANGED
|
@@ -68,6 +68,16 @@ require_relative 'tools/query_knowledge'
|
|
|
68
68
|
require_relative 'tools/knowledge_health'
|
|
69
69
|
require_relative 'tools/knowledge_context'
|
|
70
70
|
require_relative 'tools/absorb'
|
|
71
|
+
require_relative 'tools/structural_index'
|
|
72
|
+
require_relative 'tools/tool_audit'
|
|
73
|
+
require_relative 'tools/state_diff'
|
|
74
|
+
require_relative 'tools/search_sessions'
|
|
75
|
+
require_relative 'structural_index'
|
|
76
|
+
require_relative 'state_tracker'
|
|
77
|
+
require_relative 'tool_quality'
|
|
78
|
+
require_relative 'deferred_registry'
|
|
79
|
+
require_relative 'catalog_dispatcher'
|
|
80
|
+
require_relative 'dynamic_injector'
|
|
71
81
|
require_relative 'catalog_bridge'
|
|
72
82
|
require_relative 'resources/runner_catalog'
|
|
73
83
|
require_relative 'resources/extension_info'
|
|
@@ -135,7 +145,11 @@ module Legion
|
|
|
135
145
|
Tools::QueryKnowledge,
|
|
136
146
|
Tools::KnowledgeHealth,
|
|
137
147
|
Tools::KnowledgeContext,
|
|
138
|
-
Tools::Absorb
|
|
148
|
+
Tools::Absorb,
|
|
149
|
+
Tools::StructuralIndexTool,
|
|
150
|
+
Tools::ToolAudit,
|
|
151
|
+
Tools::StateDiff,
|
|
152
|
+
Tools::SearchSessions
|
|
139
153
|
].freeze
|
|
140
154
|
|
|
141
155
|
@tool_registry = Concurrent::Array.new(STATIC_TOOLS)
|
|
@@ -189,21 +203,12 @@ module Legion
|
|
|
189
203
|
end
|
|
190
204
|
end
|
|
191
205
|
|
|
192
|
-
server
|
|
193
|
-
build_filtered_tool_list.map(&:to_h)
|
|
194
|
-
end
|
|
206
|
+
install_deferred_tools_list_handler(server)
|
|
195
207
|
|
|
196
|
-
# Hydrate pattern store from L2 persistence (SQLite) on boot
|
|
197
208
|
PatternStore.hydrate_from_l2 if defined?(PatternStore)
|
|
198
|
-
|
|
199
|
-
# Cold-start: load community patterns if store is still empty after hydration
|
|
200
209
|
ColdStart.load_community_patterns if defined?(ColdStart)
|
|
201
|
-
|
|
202
|
-
# Discover and register runner functions before building the embedding index
|
|
203
|
-
# so all tools are present when embeddings are populated
|
|
204
210
|
FunctionDiscovery.discover_and_register if defined?(Legion::Extensions)
|
|
205
|
-
|
|
206
|
-
# Populate embedding index for semantic tool matching (lazy — no-op if LLM unavailable)
|
|
211
|
+
register_catalog_tools
|
|
207
212
|
populate_embedding_index
|
|
208
213
|
|
|
209
214
|
Resources::RunnerCatalog.register(server)
|
|
@@ -262,6 +267,15 @@ module Legion
|
|
|
262
267
|
|
|
263
268
|
private
|
|
264
269
|
|
|
270
|
+
def install_deferred_tools_list_handler(server)
|
|
271
|
+
handlers = server.instance_variable_get(:@handlers)
|
|
272
|
+
return unless handlers
|
|
273
|
+
|
|
274
|
+
handlers[::MCP::Methods::TOOLS_LIST] = lambda { |_request|
|
|
275
|
+
DeferredRegistry.build_tools_list(build_filtered_tool_list)
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
|
|
265
279
|
def instructions
|
|
266
280
|
<<~TEXT
|
|
267
281
|
Legion is an async job engine. You can run tasks, create chains and relationships between services, manage extensions, and query system status.
|
data/lib/legion/mcp/settings.rb
CHANGED
|
@@ -13,10 +13,19 @@ module Legion
|
|
|
13
13
|
connect_timeout: 10,
|
|
14
14
|
call_timeout: 30,
|
|
15
15
|
codegen: { self_generate: self_generate_defaults },
|
|
16
|
-
mcp: { auto_expose_runners: false
|
|
16
|
+
mcp: { auto_expose_runners: false, deferred_loading: deferred_loading_defaults,
|
|
17
|
+
dynamic_tools: dynamic_tools_defaults }
|
|
17
18
|
}
|
|
18
19
|
end
|
|
19
20
|
|
|
21
|
+
def deferred_loading_defaults
|
|
22
|
+
{ enabled: true, always_loaded: [] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def dynamic_tools_defaults
|
|
26
|
+
{ enabled: false, max_injected: 10 }
|
|
27
|
+
end
|
|
28
|
+
|
|
20
29
|
def self_generate_defaults
|
|
21
30
|
{
|
|
22
31
|
enabled: false,
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module StateTracker
|
|
6
|
+
MAX_SNAPSHOTS = 50
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def snapshot
|
|
11
|
+
state = collect_state
|
|
12
|
+
timestamp = Time.now.floor
|
|
13
|
+
|
|
14
|
+
snapshots_mutex.synchronize do
|
|
15
|
+
snapshots << { state: state, timestamp: timestamp }
|
|
16
|
+
snapshots.shift if snapshots.size > MAX_SNAPSHOTS
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
{ state: state, timestamp: timestamp.iso8601 }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def diff(since:)
|
|
23
|
+
since_time = parse_time(since)
|
|
24
|
+
return { error: 'invalid timestamp' } unless since_time
|
|
25
|
+
|
|
26
|
+
baseline = find_baseline(since_time)
|
|
27
|
+
current = collect_state
|
|
28
|
+
|
|
29
|
+
if baseline.nil?
|
|
30
|
+
return { full_state: current, reason: 'no baseline found for given timestamp',
|
|
31
|
+
timestamp: Time.now.iso8601 }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
changes = compute_diff(baseline[:state], current)
|
|
35
|
+
{ changes: changes, since: since_time.iso8601, timestamp: Time.now.iso8601 }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def collect_state
|
|
39
|
+
{
|
|
40
|
+
tool_count: Server.tool_registry.size,
|
|
41
|
+
observer_stats: collect_observer_stats,
|
|
42
|
+
pattern_count: collect_pattern_count,
|
|
43
|
+
extensions: collect_extension_count
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def collect_observer_stats
|
|
48
|
+
return {} unless defined?(Observer)
|
|
49
|
+
|
|
50
|
+
stats = Observer.stats
|
|
51
|
+
{ total_calls: stats[:total_calls], tool_count: stats[:tool_count], failure_rate: stats[:failure_rate] }
|
|
52
|
+
rescue StandardError
|
|
53
|
+
{}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def collect_pattern_count
|
|
57
|
+
return 0 unless defined?(PatternStore)
|
|
58
|
+
|
|
59
|
+
PatternStore.respond_to?(:size) ? PatternStore.size : 0
|
|
60
|
+
rescue StandardError
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def collect_extension_count
|
|
65
|
+
return 0 unless defined?(Legion::Extensions)
|
|
66
|
+
|
|
67
|
+
extensions = if Legion::Extensions.respond_to?(:extensions)
|
|
68
|
+
Legion::Extensions.extensions
|
|
69
|
+
else
|
|
70
|
+
Legion::Extensions.instance_variable_get(:@extensions)
|
|
71
|
+
end
|
|
72
|
+
extensions&.size || 0
|
|
73
|
+
rescue StandardError
|
|
74
|
+
0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def compute_diff(baseline, current)
|
|
78
|
+
changes = {}
|
|
79
|
+
all_keys = (baseline.keys | current.keys).uniq
|
|
80
|
+
|
|
81
|
+
all_keys.each do |key|
|
|
82
|
+
old_val = baseline[key]
|
|
83
|
+
new_val = current[key]
|
|
84
|
+
|
|
85
|
+
next if old_val == new_val
|
|
86
|
+
|
|
87
|
+
if old_val.is_a?(Hash) && new_val.is_a?(Hash)
|
|
88
|
+
nested = compute_diff(old_val, new_val)
|
|
89
|
+
changes[key] = nested unless nested.empty?
|
|
90
|
+
else
|
|
91
|
+
changes[key] = { before: old_val, after: new_val }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
changes
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def find_baseline(since_time)
|
|
99
|
+
snapshots_mutex.synchronize do
|
|
100
|
+
snapshots.reverse.find { |s| s[:timestamp] <= since_time }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def parse_time(value)
|
|
105
|
+
case value
|
|
106
|
+
when Time
|
|
107
|
+
value
|
|
108
|
+
when String
|
|
109
|
+
Time.parse(value)
|
|
110
|
+
when Numeric
|
|
111
|
+
Time.at(value)
|
|
112
|
+
end
|
|
113
|
+
rescue ArgumentError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def snapshots
|
|
118
|
+
@snapshots ||= []
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def snapshots_mutex
|
|
122
|
+
@snapshots_mutex ||= Mutex.new
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def reset!
|
|
126
|
+
snapshots_mutex.synchronize { snapshots.clear }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module MCP
|
|
7
|
+
module StructuralIndex
|
|
8
|
+
CACHE_PATH = File.expand_path('~/.legionio/cache/structural_index.json')
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def build
|
|
13
|
+
{
|
|
14
|
+
extensions: scan_extensions,
|
|
15
|
+
tools: scan_tools,
|
|
16
|
+
generated_at: Time.now.iso8601
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def scan_extensions
|
|
21
|
+
return [] unless defined?(Legion::Extensions)
|
|
22
|
+
|
|
23
|
+
extensions = if Legion::Extensions.respond_to?(:extensions)
|
|
24
|
+
Legion::Extensions.extensions || []
|
|
25
|
+
else
|
|
26
|
+
Legion::Extensions.instance_variable_get(:@extensions) || []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
extensions.filter_map do |ext|
|
|
30
|
+
build_extension_entry(ext)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
Legion::Logging.debug("StructuralIndex: skipping #{ext}: #{e.message}") if defined?(Legion::Logging)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_extension_entry(ext)
|
|
38
|
+
runners = if ext.respond_to?(:runner_modules)
|
|
39
|
+
ext.runner_modules.filter_map { |rm| build_runner_entry(rm) }
|
|
40
|
+
else
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
actors = if ext.respond_to?(:actor_modules)
|
|
45
|
+
ext.actor_modules.filter_map { |am| build_actor_entry(am) }
|
|
46
|
+
else
|
|
47
|
+
[]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
name = ext.respond_to?(:extension_name) ? ext.extension_name : ext.class.name
|
|
51
|
+
|
|
52
|
+
{
|
|
53
|
+
name: name,
|
|
54
|
+
runners: runners,
|
|
55
|
+
actors: actors
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def build_runner_entry(runner_mod)
|
|
60
|
+
settings = runner_mod.respond_to?(:settings) ? runner_mod.settings : {}
|
|
61
|
+
functions = settings.is_a?(Hash) ? (settings[:functions] || {}) : {}
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
name: runner_mod.respond_to?(:name) ? runner_mod.name : runner_mod.to_s,
|
|
65
|
+
functions: functions.keys.map(&:to_s)
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_actor_entry(actor_mod)
|
|
70
|
+
{
|
|
71
|
+
name: actor_mod.respond_to?(:name) ? actor_mod.name : actor_mod.to_s,
|
|
72
|
+
type: actor_mod.respond_to?(:actor_type) ? actor_mod.actor_type : 'unknown'
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def scan_tools
|
|
77
|
+
Server.tool_registry.map do |tc|
|
|
78
|
+
{
|
|
79
|
+
name: tc.tool_name,
|
|
80
|
+
description: tc.description,
|
|
81
|
+
catalog: tc.respond_to?(:catalog_entry) && tc.catalog_entry ? true : false
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cached
|
|
87
|
+
return nil unless File.exist?(CACHE_PATH)
|
|
88
|
+
|
|
89
|
+
data = File.read(CACHE_PATH)
|
|
90
|
+
Legion::JSON.load(data)
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def save_cache(index = nil)
|
|
96
|
+
index ||= build
|
|
97
|
+
dir = File.dirname(CACHE_PATH)
|
|
98
|
+
FileUtils.mkdir_p(dir) unless File.directory?(dir)
|
|
99
|
+
File.write(CACHE_PATH, Legion::JSON.dump(index))
|
|
100
|
+
index
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def invalidate_cache
|
|
104
|
+
FileUtils.rm_f(CACHE_PATH)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def load_or_build
|
|
108
|
+
cached || save_cache(build)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def filter(index, extension: nil, type: nil)
|
|
112
|
+
result = index.dup
|
|
113
|
+
result[:extensions] = result[:extensions]&.select { |e| e[:name]&.include?(extension) } || [] if extension
|
|
114
|
+
apply_type_filter(result, type) if type
|
|
115
|
+
result
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def apply_type_filter(result, type)
|
|
119
|
+
case type
|
|
120
|
+
when 'tools'
|
|
121
|
+
result.delete(:extensions)
|
|
122
|
+
when 'extensions'
|
|
123
|
+
result.delete(:tools)
|
|
124
|
+
when 'runners'
|
|
125
|
+
result[:extensions]&.each { |e| e.delete(:actors) }
|
|
126
|
+
result.delete(:tools)
|
|
127
|
+
when 'actors'
|
|
128
|
+
result[:extensions]&.each { |e| e.delete(:runners) }
|
|
129
|
+
result.delete(:tools)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module ToolQuality
|
|
6
|
+
MIN_DESCRIPTION_LENGTH = 20
|
|
7
|
+
MIN_PARAM_DESCRIPTION_LENGTH = 5
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def audit_all
|
|
12
|
+
Server.tool_registry.map { |tc| audit_tool(tc) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def audit_tool(tool_class)
|
|
16
|
+
issues = []
|
|
17
|
+
issues.concat(check_description(tool_class))
|
|
18
|
+
issues.concat(check_params(tool_class))
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
name: tool_class.tool_name,
|
|
22
|
+
description: tool_class.description,
|
|
23
|
+
category: resolve_category(tool_class),
|
|
24
|
+
issues: issues,
|
|
25
|
+
quality: issues.empty? ? :pass : :warn
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def check_description(tool_class)
|
|
30
|
+
issues = []
|
|
31
|
+
desc = tool_class.description.to_s
|
|
32
|
+
issues << 'description missing' if desc.empty?
|
|
33
|
+
issues << "description too short (#{desc.length} chars, min #{MIN_DESCRIPTION_LENGTH})" if desc.length < MIN_DESCRIPTION_LENGTH
|
|
34
|
+
issues
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def check_params(tool_class)
|
|
38
|
+
issues = []
|
|
39
|
+
raw_schema = tool_class.input_schema
|
|
40
|
+
schema = raw_schema.is_a?(Hash) ? raw_schema : raw_schema.to_h
|
|
41
|
+
properties = schema[:properties] || {}
|
|
42
|
+
|
|
43
|
+
properties.each do |param_name, meta|
|
|
44
|
+
meta_hash = meta.is_a?(Hash) ? meta : {}
|
|
45
|
+
desc = meta_hash[:description].to_s
|
|
46
|
+
issues << "param '#{param_name}' missing or short description" if desc.length < MIN_PARAM_DESCRIPTION_LENGTH
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
issues
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def resolve_category(tool_class)
|
|
53
|
+
return tool_class.mcp_category.to_sym if tool_class.respond_to?(:mcp_category) && tool_class.mcp_category
|
|
54
|
+
|
|
55
|
+
ContextCompiler::CATEGORIES.each do |cat, config|
|
|
56
|
+
return cat if config[:tools].include?(tool_class.tool_name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
EXPANDED_CATEGORIES.each do |cat, config|
|
|
60
|
+
return cat if config[:tools].include?(tool_class.tool_name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
:uncategorized
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def capability_matrix
|
|
67
|
+
Server.tool_registry.map do |tc|
|
|
68
|
+
raw_schema = tc.input_schema
|
|
69
|
+
schema = raw_schema.is_a?(Hash) ? raw_schema : raw_schema.to_h
|
|
70
|
+
properties = schema[:properties] || {}
|
|
71
|
+
required = schema[:required] || []
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
name: tc.tool_name,
|
|
75
|
+
category: resolve_category(tc),
|
|
76
|
+
param_count: properties.size,
|
|
77
|
+
required: required.map(&:to_s),
|
|
78
|
+
reads: reads?(tc),
|
|
79
|
+
writes: writes?(tc),
|
|
80
|
+
catalog: tc.respond_to?(:catalog_entry) && tc.catalog_entry
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def reads?(tool_class)
|
|
86
|
+
name = tool_class.tool_name
|
|
87
|
+
name.start_with?('legion.list_', 'legion.get_', 'legion.show_') ||
|
|
88
|
+
name.include?('query') || name.include?('search') ||
|
|
89
|
+
name.include?('status') || name.include?('health') ||
|
|
90
|
+
name.include?('stats') || name.include?('describe')
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def writes?(tool_class)
|
|
94
|
+
name = tool_class.tool_name
|
|
95
|
+
name.start_with?('legion.create_', 'legion.update_', 'legion.delete_') ||
|
|
96
|
+
name.start_with?('legion.enable_', 'legion.disable_') ||
|
|
97
|
+
name.include?('run') || name.include?('approve') ||
|
|
98
|
+
name.include?('propose') || name.include?('absorb') ||
|
|
99
|
+
name.include?('broadcast') || name.include?('notify')
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def summary
|
|
103
|
+
results = audit_all
|
|
104
|
+
pass_count = results.count { |r| r[:quality] == :pass }
|
|
105
|
+
warn_count = results.count { |r| r[:quality] == :warn }
|
|
106
|
+
categories = results.group_by { |r| r[:category] }
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
total_tools: results.size,
|
|
110
|
+
passing: pass_count,
|
|
111
|
+
warnings: warn_count,
|
|
112
|
+
by_category: categories.transform_values(&:size),
|
|
113
|
+
issues: results.select { |r| r[:quality] == :warn }
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
EXPANDED_CATEGORIES = {
|
|
118
|
+
knowledge: {
|
|
119
|
+
tools: %w[legion.query_knowledge legion.knowledge_health legion.knowledge_context legion.absorb],
|
|
120
|
+
summary: 'Knowledge base operations — query, health, context retrieval, content absorption.'
|
|
121
|
+
},
|
|
122
|
+
mesh: {
|
|
123
|
+
tools: %w[legion.ask_peer legion.list_peers legion.notify_peer legion.broadcast_peers legion.mesh_status],
|
|
124
|
+
summary: 'Agent mesh communication — peer queries, notifications, broadcasts, and mesh topology.'
|
|
125
|
+
},
|
|
126
|
+
mind_growth: {
|
|
127
|
+
tools: %w[legion.mind_growth_status legion.mind_growth_propose legion.mind_growth_approve
|
|
128
|
+
legion.mind_growth_build_queue legion.mind_growth_cognitive_profile legion.mind_growth_health],
|
|
129
|
+
summary: 'Cognitive growth — proposals, approvals, build queue, cognitive profiling, fitness scores.'
|
|
130
|
+
},
|
|
131
|
+
prompts: {
|
|
132
|
+
tools: %w[legion.prompt_list legion.prompt_show legion.prompt_run],
|
|
133
|
+
summary: 'Prompt template management — list, view, and render prompt templates.'
|
|
134
|
+
},
|
|
135
|
+
datasets: {
|
|
136
|
+
tools: %w[legion.dataset_list legion.dataset_show legion.experiment_results],
|
|
137
|
+
summary: 'Dataset and experiment browsing — list datasets, view rows, compare experiment results.'
|
|
138
|
+
},
|
|
139
|
+
evals: {
|
|
140
|
+
tools: %w[legion.eval_list legion.eval_run legion.eval_results],
|
|
141
|
+
summary: 'Evaluation management — list evaluators, run evaluations, view results.'
|
|
142
|
+
},
|
|
143
|
+
meta: {
|
|
144
|
+
tools: %w[legion.do legion.tools legion.plan_action legion.structural_index],
|
|
145
|
+
summary: 'Meta-tools — natural language routing, tool discovery, planning, structural index.'
|
|
146
|
+
}
|
|
147
|
+
}.freeze
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -5,28 +5,37 @@ module Legion
|
|
|
5
5
|
module Tools
|
|
6
6
|
class DiscoverTools < ::MCP::Tool
|
|
7
7
|
tool_name 'legion.tools'
|
|
8
|
-
description 'Discover available Legion tools by category or intent. Returns compressed definitions to reduce context.'
|
|
8
|
+
description 'Discover available Legion tools by category or intent. Returns compressed definitions to reduce context. ' \
|
|
9
|
+
'Use tool_names with schema: true to load full schemas for deferred tools.'
|
|
9
10
|
|
|
10
11
|
input_schema(
|
|
11
12
|
properties: {
|
|
12
|
-
category:
|
|
13
|
+
category: {
|
|
13
14
|
type: 'string',
|
|
14
15
|
description: 'Tool category: tasks, chains, relationships, extensions, schedules, workers, rbac, status, describe'
|
|
15
16
|
},
|
|
16
|
-
intent:
|
|
17
|
+
intent: {
|
|
17
18
|
type: 'string',
|
|
18
19
|
description: 'Describe what you want to do and relevant tools will be ranked'
|
|
20
|
+
},
|
|
21
|
+
tool_names: {
|
|
22
|
+
type: 'array',
|
|
23
|
+
items: { type: 'string' },
|
|
24
|
+
description: 'Specific tool names to retrieve full schemas for (e.g., ["legion.ask_peer", "legion.list_peers"])'
|
|
25
|
+
},
|
|
26
|
+
schema: {
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
description: 'When true with tool_names, returns full JSON schemas for the specified tools'
|
|
19
29
|
}
|
|
20
30
|
}
|
|
21
31
|
)
|
|
22
32
|
|
|
23
33
|
class << self
|
|
24
|
-
def call(category: nil, intent: nil)
|
|
25
|
-
if
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
text_response(result)
|
|
34
|
+
def call(category: nil, intent: nil, tool_names: nil, schema: nil)
|
|
35
|
+
if tool_names && schema
|
|
36
|
+
resolve_schemas(tool_names)
|
|
37
|
+
elsif category
|
|
38
|
+
lookup_category(category)
|
|
30
39
|
elsif intent
|
|
31
40
|
results = ContextCompiler.match_tools(intent, limit: 5)
|
|
32
41
|
text_response({ matched_tools: results })
|
|
@@ -40,6 +49,22 @@ module Legion
|
|
|
40
49
|
|
|
41
50
|
private
|
|
42
51
|
|
|
52
|
+
def resolve_schemas(tool_names)
|
|
53
|
+
schemas = DeferredRegistry.resolve_schemas(tool_names, Server.tool_registry)
|
|
54
|
+
if schemas.empty?
|
|
55
|
+
error_response("No tools found matching: #{tool_names.join(', ')}")
|
|
56
|
+
else
|
|
57
|
+
text_response({ schemas: schemas })
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def lookup_category(category)
|
|
62
|
+
result = ContextCompiler.category_tools(category.to_sym)
|
|
63
|
+
return error_response("Unknown category: #{category}") if result.nil?
|
|
64
|
+
|
|
65
|
+
text_response(result)
|
|
66
|
+
end
|
|
67
|
+
|
|
43
68
|
def text_response(data)
|
|
44
69
|
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
45
70
|
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class SearchSessions < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.search_sessions'
|
|
8
|
+
description 'Search across past conversation sessions by keyword or topic. Returns matching ' \
|
|
9
|
+
'sessions with context snippets sorted by relevance.'
|
|
10
|
+
|
|
11
|
+
SESSIONS_DIR = File.expand_path('~/.legion/sessions')
|
|
12
|
+
|
|
13
|
+
input_schema(
|
|
14
|
+
properties: {
|
|
15
|
+
query: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Search query — keywords or topic to find in past sessions'
|
|
18
|
+
},
|
|
19
|
+
limit: {
|
|
20
|
+
type: 'integer',
|
|
21
|
+
description: 'Maximum number of results to return (default 5)'
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
required: ['query']
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def call(query:, limit: 5)
|
|
29
|
+
return error_response('query cannot be empty') if query.to_s.strip.empty?
|
|
30
|
+
|
|
31
|
+
results = search(query, limit: limit)
|
|
32
|
+
text_response({ query: query, results: results, total: results.size })
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Legion::Logging.warn("SearchSessions#call failed: #{e.message}") if defined?(Legion::Logging)
|
|
35
|
+
error_response("Failed: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def search(query, limit: 5)
|
|
41
|
+
sessions_dir = resolve_sessions_dir
|
|
42
|
+
return [] unless sessions_dir && Dir.exist?(sessions_dir)
|
|
43
|
+
|
|
44
|
+
pattern = query.downcase
|
|
45
|
+
matches = Dir.glob(File.join(sessions_dir, '*.json')).filter_map do |path|
|
|
46
|
+
match_session(path, pattern)
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
matches.sort_by { |r| -r[:matches] }.first(limit)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def match_session(path, pattern)
|
|
54
|
+
data = Legion::JSON.load(File.read(path))
|
|
55
|
+
messages = data[:messages] || data['messages'] || []
|
|
56
|
+
matches = messages.count { |m| content_matches?(m, pattern) }
|
|
57
|
+
return nil if matches.zero?
|
|
58
|
+
|
|
59
|
+
first_match = messages.find { |m| content_matches?(m, pattern) }
|
|
60
|
+
context = extract_context(first_match, pattern)
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
session: data[:name] || data['name'] || File.basename(path, '.json'),
|
|
64
|
+
file: File.basename(path),
|
|
65
|
+
matches: matches,
|
|
66
|
+
context: context
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def content_matches?(message, pattern)
|
|
71
|
+
content = message[:content] || message['content']
|
|
72
|
+
content.to_s.downcase.include?(pattern)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def extract_context(message, pattern)
|
|
76
|
+
content = (message[:content] || message['content']).to_s
|
|
77
|
+
idx = content.downcase.index(pattern)
|
|
78
|
+
return content[0..200] unless idx
|
|
79
|
+
|
|
80
|
+
start = [idx - 50, 0].max
|
|
81
|
+
content[start, 200]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def resolve_sessions_dir
|
|
85
|
+
custom = Legion::Settings.dig(:chat, :sessions_dir) if defined?(Legion::Settings)
|
|
86
|
+
custom || SESSIONS_DIR
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def text_response(data)
|
|
90
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def error_response(msg)
|
|
94
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class StateDiff < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.state_diff'
|
|
8
|
+
description 'Return only changed system state since a given timestamp. Token-efficient polling ' \
|
|
9
|
+
'for agents monitoring system state without re-fetching everything.'
|
|
10
|
+
|
|
11
|
+
input_schema(
|
|
12
|
+
properties: {
|
|
13
|
+
since: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'ISO 8601 timestamp to diff against (e.g., "2026-03-31T12:00:00Z")'
|
|
16
|
+
},
|
|
17
|
+
snapshot: {
|
|
18
|
+
type: 'boolean',
|
|
19
|
+
description: 'When true, takes a state snapshot and returns it (use before polling with since:)'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def call(since: nil, snapshot: nil)
|
|
26
|
+
if snapshot
|
|
27
|
+
result = StateTracker.snapshot
|
|
28
|
+
text_response(result)
|
|
29
|
+
elsif since
|
|
30
|
+
result = StateTracker.diff(since: since)
|
|
31
|
+
text_response(result)
|
|
32
|
+
else
|
|
33
|
+
text_response(StateTracker.collect_state.merge(timestamp: Time.now.iso8601))
|
|
34
|
+
end
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
Legion::Logging.warn("StateDiff#call failed: #{e.message}") if defined?(Legion::Logging)
|
|
37
|
+
error_response("Failed: #{e.message}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def text_response(data)
|
|
43
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def error_response(msg)
|
|
47
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class StructuralIndexTool < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.structural_index'
|
|
8
|
+
description 'Return the precomputed structural index of all extensions, runners, actors, and tools. ' \
|
|
9
|
+
'Filter by extension name or type (tools, extensions, runners, actors).'
|
|
10
|
+
|
|
11
|
+
input_schema(
|
|
12
|
+
properties: {
|
|
13
|
+
extension: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Filter by extension name (partial match)'
|
|
16
|
+
},
|
|
17
|
+
type: {
|
|
18
|
+
type: 'string',
|
|
19
|
+
description: 'Filter by type: tools, extensions, runners, actors',
|
|
20
|
+
enum: %w[tools extensions runners actors]
|
|
21
|
+
},
|
|
22
|
+
refresh: {
|
|
23
|
+
type: 'boolean',
|
|
24
|
+
description: 'Force rebuild of the index (ignores cache)'
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
def call(extension: nil, type: nil, refresh: nil)
|
|
31
|
+
index = if refresh
|
|
32
|
+
StructuralIndex.save_cache(StructuralIndex.build)
|
|
33
|
+
else
|
|
34
|
+
StructuralIndex.load_or_build
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result = StructuralIndex.filter(index, extension: extension, type: type)
|
|
38
|
+
text_response(result)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
Legion::Logging.warn("StructuralIndexTool#call failed: #{e.message}") if defined?(Legion::Logging)
|
|
41
|
+
error_response("Failed: #{e.message}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def text_response(data)
|
|
47
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def error_response(msg)
|
|
51
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class ToolAudit < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.tool_audit'
|
|
8
|
+
description 'Audit MCP tools for quality, categorization, and capability matrix. ' \
|
|
9
|
+
'Returns issues, category assignments, and read/write capabilities.'
|
|
10
|
+
|
|
11
|
+
input_schema(
|
|
12
|
+
properties: {
|
|
13
|
+
mode: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Audit mode: summary (default), matrix (capability matrix), issues (quality issues only)',
|
|
16
|
+
enum: %w[summary matrix issues]
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def call(mode: 'summary')
|
|
23
|
+
result = case mode
|
|
24
|
+
when 'matrix'
|
|
25
|
+
ToolQuality.capability_matrix
|
|
26
|
+
when 'issues'
|
|
27
|
+
ToolQuality.audit_all.select { |r| r[:quality] == :warn }
|
|
28
|
+
else
|
|
29
|
+
ToolQuality.summary
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
text_response(result)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Legion::Logging.warn("ToolAudit#call failed: #{e.message}") if defined?(Legion::Logging)
|
|
35
|
+
error_response("Failed: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def text_response(data)
|
|
41
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def error_response(msg)
|
|
45
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/legion/mcp/version.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.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -110,6 +110,7 @@ files:
|
|
|
110
110
|
- ".gitignore"
|
|
111
111
|
- ".rspec"
|
|
112
112
|
- ".rubocop.yml"
|
|
113
|
+
- AGENTS.md
|
|
113
114
|
- CHANGELOG.md
|
|
114
115
|
- CLAUDE.md
|
|
115
116
|
- CODEOWNERS
|
|
@@ -122,6 +123,7 @@ files:
|
|
|
122
123
|
- lib/legion/mcp/actors/self_generate_cycle.rb
|
|
123
124
|
- lib/legion/mcp/auth.rb
|
|
124
125
|
- lib/legion/mcp/catalog_bridge.rb
|
|
126
|
+
- lib/legion/mcp/catalog_dispatcher.rb
|
|
125
127
|
- lib/legion/mcp/client.rb
|
|
126
128
|
- lib/legion/mcp/client/connection.rb
|
|
127
129
|
- lib/legion/mcp/client/pool.rb
|
|
@@ -129,6 +131,8 @@ files:
|
|
|
129
131
|
- lib/legion/mcp/cold_start.rb
|
|
130
132
|
- lib/legion/mcp/context_compiler.rb
|
|
131
133
|
- lib/legion/mcp/context_guard.rb
|
|
134
|
+
- lib/legion/mcp/deferred_registry.rb
|
|
135
|
+
- lib/legion/mcp/dynamic_injector.rb
|
|
132
136
|
- lib/legion/mcp/embedding_index.rb
|
|
133
137
|
- lib/legion/mcp/function_discovery.rb
|
|
134
138
|
- lib/legion/mcp/gap_detector.rb
|
|
@@ -144,8 +148,11 @@ files:
|
|
|
144
148
|
- lib/legion/mcp/self_generate.rb
|
|
145
149
|
- lib/legion/mcp/server.rb
|
|
146
150
|
- lib/legion/mcp/settings.rb
|
|
151
|
+
- lib/legion/mcp/state_tracker.rb
|
|
152
|
+
- lib/legion/mcp/structural_index.rb
|
|
147
153
|
- lib/legion/mcp/tier_router.rb
|
|
148
154
|
- lib/legion/mcp/tool_governance.rb
|
|
155
|
+
- lib/legion/mcp/tool_quality.rb
|
|
149
156
|
- lib/legion/mcp/tools/absorb.rb
|
|
150
157
|
- lib/legion/mcp/tools/ask_peer.rb
|
|
151
158
|
- lib/legion/mcp/tools/broadcast_peers.rb
|
|
@@ -199,8 +206,12 @@ files:
|
|
|
199
206
|
- lib/legion/mcp/tools/rbac_grants.rb
|
|
200
207
|
- lib/legion/mcp/tools/routing_stats.rb
|
|
201
208
|
- lib/legion/mcp/tools/run_task.rb
|
|
209
|
+
- lib/legion/mcp/tools/search_sessions.rb
|
|
202
210
|
- lib/legion/mcp/tools/show_worker.rb
|
|
211
|
+
- lib/legion/mcp/tools/state_diff.rb
|
|
212
|
+
- lib/legion/mcp/tools/structural_index.rb
|
|
203
213
|
- lib/legion/mcp/tools/team_summary.rb
|
|
214
|
+
- lib/legion/mcp/tools/tool_audit.rb
|
|
204
215
|
- lib/legion/mcp/tools/update_chain.rb
|
|
205
216
|
- lib/legion/mcp/tools/update_relationship.rb
|
|
206
217
|
- lib/legion/mcp/tools/update_schedule.rb
|