legionio 1.4.70 → 1.4.72
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 +22 -0
- data/CLAUDE.md +20 -15
- data/lib/legion/api/llm.rb +90 -0
- data/lib/legion/api.rb +2 -0
- data/lib/legion/cli/observe_command.rb +94 -0
- data/lib/legion/cli.rb +5 -1
- data/lib/legion/mcp/context_compiler.rb +142 -0
- data/lib/legion/mcp/observer.rb +135 -0
- data/lib/legion/mcp/server.rb +40 -1
- data/lib/legion/mcp/tools/discover_tools.rb +53 -0
- data/lib/legion/mcp/tools/do_action.rb +55 -0
- data/lib/legion/mcp/usage_filter.rb +86 -0
- data/lib/legion/version.rb +1 -1
- metadata +8 -4
- data/lib/legion/cli/tty/chat_ui.rb +0 -220
- data/lib/legion/cli/tty/palette.rb +0 -81
- data/lib/legion/cli/tty/splash.rb +0 -121
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9c2f9048a25ea9cff5f0b8db43aaaf49ab40bb6c388b2f892c3f952fb7233f4
|
|
4
|
+
data.tar.gz: 02225b7029518b45f1d78f632cce4fdbaee631efb9c03cade5e575ac3c96e3cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 758ee671a15875e26a60fe87b10b3499c5ef47f766d5cd3d43498b7c362be4a791defe79b99bda56ccbf263558fa7a4e8450b307f3bc29734c3c3d279bba4dd4
|
|
7
|
+
data.tar.gz: 2c04803613fe2a2ba39ef4aa29865f66b96b237ba7c3f74920cd784bedfebc9bc90df91042714a016f06587a6d234ed722cf29a5727c039e8f09fbddfb322184
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.72] - 2026-03-19
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- TBI Phase 0+2: MCP tool observation pipeline and usage-based filtering
|
|
7
|
+
- `Legion::MCP::Observer` module: in-memory tool call recording with counters, ring buffer, and intent tracking
|
|
8
|
+
- `Legion::MCP::UsageFilter` module: scores tools by frequency, recency, and keyword match; prunes dead tools
|
|
9
|
+
- MCP `instrumentation_callback` wiring: automatically records all `tools/call` invocations via Observer
|
|
10
|
+
- MCP `tools_list_handler` wiring: dynamically filters and ranks tools per-request based on usage data
|
|
11
|
+
- `legion observe` CLI command: `stats`, `recent`, `reset` subcommands for MCP tool usage inspection
|
|
12
|
+
- 96 new specs covering Observer, UsageFilter, CLI command, and integration wiring
|
|
13
|
+
|
|
14
|
+
## [1.4.71] - 2026-03-19
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `POST /api/llm/chat` daemon endpoint with async (202) and sync (201) response paths
|
|
18
|
+
- `ContextCompiler` module: categorizes 35 MCP tools into 9 groups with keyword matching
|
|
19
|
+
- `legion.do` meta-tool: natural language intent routing to best-matching MCP tool
|
|
20
|
+
- `legion.tools` meta-tool: compressed catalog, category browsing, and intent-matched discovery
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- `ContextCompiler.build_tool_index` now handles `MCP::Tool::InputSchema` objects (not just hashes)
|
|
24
|
+
|
|
3
25
|
## [1.4.70] - 2026-03-19
|
|
4
26
|
|
|
5
27
|
### Added
|
data/CLAUDE.md
CHANGED
|
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
|
|
|
9
9
|
|
|
10
10
|
**GitHub**: https://github.com/LegionIO/LegionIO
|
|
11
11
|
**Gem**: `legionio`
|
|
12
|
-
**Version**: 1.4.
|
|
12
|
+
**Version**: 1.4.70
|
|
13
13
|
**License**: Apache-2.0
|
|
14
14
|
**Docker**: `legionio/legion`
|
|
15
15
|
**Ruby**: >= 3.4
|
|
@@ -39,21 +39,26 @@ Before any Legion code loads, `exe/legion` applies three performance optimizatio
|
|
|
39
39
|
```
|
|
40
40
|
Legion.start
|
|
41
41
|
└── Legion::Service.new
|
|
42
|
-
├── 1.
|
|
43
|
-
├── 2.
|
|
44
|
-
├── 3.
|
|
45
|
-
├── 4.
|
|
46
|
-
├── 5.
|
|
47
|
-
├── 6.
|
|
48
|
-
├── 7.
|
|
49
|
-
├── 8.
|
|
50
|
-
├── 9.
|
|
51
|
-
├── 10.
|
|
52
|
-
|
|
42
|
+
├── 1. setup_logging (legion-logging)
|
|
43
|
+
├── 2. setup_settings (legion-settings, loads /etc/legionio, ~/legionio, ./settings)
|
|
44
|
+
├── 3. Legion::Crypt.start (legion-crypt, Vault connection)
|
|
45
|
+
├── 4. setup_transport (legion-transport, RabbitMQ connection)
|
|
46
|
+
├── 5. require legion-cache
|
|
47
|
+
├── 6. setup_data (legion-data, MySQL/SQLite + migrations, optional)
|
|
48
|
+
├── 7. setup_rbac (legion-rbac, optional)
|
|
49
|
+
├── 8. setup_llm (legion-llm, optional)
|
|
50
|
+
├── 9. setup_gaia (legion-gaia, cognitive layer, optional)
|
|
51
|
+
├── 10. setup_telemetry (OpenTelemetry, optional)
|
|
52
|
+
├── 11. setup_supervision (process supervision)
|
|
53
|
+
├── 12. load_extensions (two-phase: require+autobuild all, then hook_all_actors)
|
|
54
|
+
├── 13. Legion::Crypt.cs (distribute cluster secret)
|
|
55
|
+
└── 14. setup_api (start Sinatra/Puma on port 4567)
|
|
53
56
|
```
|
|
54
57
|
|
|
55
58
|
Each phase calls `Legion::Readiness.mark_ready(:component)`. All phases are individually toggleable via `Service.new(transport: false, ...)`.
|
|
56
59
|
|
|
60
|
+
Extension loading is two-phase: all extensions are `require`d and `autobuild` runs first, collecting actors into `@pending_actors`. After all extensions are loaded, `hook_all_actors` starts AMQP subscriptions, timers, and other actor types. This prevents race conditions where early extensions start ticking while later ones haven't loaded yet.
|
|
61
|
+
|
|
57
62
|
### Reload Sequence
|
|
58
63
|
|
|
59
64
|
`Legion.reload` shuts down all subsystems in reverse order, waits for them to drain, then re-runs setup from settings onward. Extensions and API are re-loaded fresh.
|
|
@@ -66,7 +71,7 @@ Legion (lib/legion.rb)
|
|
|
66
71
|
│ # Entry points: Legion.start, .shutdown, .reload
|
|
67
72
|
├── Process # Daemonization: PID management, signal traps (SIGINT=quit), main loop
|
|
68
73
|
├── Readiness # Startup readiness tracking
|
|
69
|
-
│ # COMPONENTS: settings, crypt, transport, cache, data, extensions, api
|
|
74
|
+
│ # COMPONENTS: settings, crypt, transport, cache, data, gaia, extensions, api
|
|
70
75
|
│ # Readiness.ready? checks all; /api/ready returns JSON status
|
|
71
76
|
├── Events # In-process pub/sub event bus
|
|
72
77
|
│ # Events.on(name) / .emit(name, **payload) / .once / .off
|
|
@@ -723,8 +728,8 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
|
|
|
723
728
|
|
|
724
729
|
```bash
|
|
725
730
|
bundle install
|
|
726
|
-
bundle exec rspec #
|
|
727
|
-
bundle exec rubocop #
|
|
731
|
+
bundle exec rspec # 1433 examples, 0 failures
|
|
732
|
+
bundle exec rubocop # 418 files, 0 offenses
|
|
728
733
|
```
|
|
729
734
|
|
|
730
735
|
Specs use `rack-test` for API testing. `Legion::JSON.load` returns symbol keys — use `body[:data]` not `body['data']` in specs.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module Routes
|
|
8
|
+
module Llm
|
|
9
|
+
def self.registered(app)
|
|
10
|
+
app.helpers do
|
|
11
|
+
define_method(:require_llm!) do
|
|
12
|
+
return if defined?(Legion::LLM) &&
|
|
13
|
+
Legion::LLM.respond_to?(:started?) &&
|
|
14
|
+
Legion::LLM.started?
|
|
15
|
+
|
|
16
|
+
halt 503, { 'Content-Type' => 'application/json' },
|
|
17
|
+
Legion::JSON.dump({ error: { code: 'llm_unavailable',
|
|
18
|
+
message: 'LLM subsystem is not available' } })
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
define_method(:cache_available?) do
|
|
22
|
+
defined?(Legion::Cache) &&
|
|
23
|
+
Legion::Cache.respond_to?(:connected?) &&
|
|
24
|
+
Legion::Cache.connected?
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
register_chat(app)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.register_chat(app)
|
|
32
|
+
app.post '/api/llm/chat' do # rubocop:disable Metrics/BlockLength
|
|
33
|
+
require_llm!
|
|
34
|
+
|
|
35
|
+
body = parse_request_body
|
|
36
|
+
validate_required!(body, :message)
|
|
37
|
+
|
|
38
|
+
request_id = body[:request_id] || SecureRandom.uuid
|
|
39
|
+
message = body[:message]
|
|
40
|
+
model = body[:model]
|
|
41
|
+
provider = body[:provider]
|
|
42
|
+
|
|
43
|
+
if cache_available?
|
|
44
|
+
llm = Legion::LLM
|
|
45
|
+
rc = Legion::LLM::ResponseCache
|
|
46
|
+
rc.init_request(request_id)
|
|
47
|
+
|
|
48
|
+
Thread.new do
|
|
49
|
+
session = llm.chat_direct(model: model, provider: provider)
|
|
50
|
+
response = session.ask(message)
|
|
51
|
+
rc.complete(
|
|
52
|
+
request_id,
|
|
53
|
+
response: response.content,
|
|
54
|
+
meta: {
|
|
55
|
+
model: session.model.to_s,
|
|
56
|
+
tokens_in: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
|
|
57
|
+
tokens_out: response.respond_to?(:output_tokens) ? response.output_tokens : nil
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
rc.fail_request(request_id, code: 'llm_error', message: e.message)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
json_response({ request_id: request_id, poll_key: "llm:#{request_id}:status" },
|
|
65
|
+
status_code: 202)
|
|
66
|
+
else
|
|
67
|
+
session = Legion::LLM.chat_direct(model: model, provider: provider)
|
|
68
|
+
response = session.ask(message)
|
|
69
|
+
json_response(
|
|
70
|
+
{
|
|
71
|
+
response: response.content,
|
|
72
|
+
meta: {
|
|
73
|
+
model: session.model.to_s,
|
|
74
|
+
tokens_in: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
|
|
75
|
+
tokens_out: response.respond_to?(:output_tokens) ? response.output_tokens : nil
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
status_code: 201
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class << self
|
|
85
|
+
private :register_chat
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/legion/api.rb
CHANGED
|
@@ -33,6 +33,7 @@ require_relative 'api/auth_kerberos'
|
|
|
33
33
|
require_relative 'api/capacity'
|
|
34
34
|
require_relative 'api/audit'
|
|
35
35
|
require_relative 'api/metrics'
|
|
36
|
+
require_relative 'api/llm'
|
|
36
37
|
|
|
37
38
|
module Legion
|
|
38
39
|
class API < Sinatra::Base
|
|
@@ -108,6 +109,7 @@ module Legion
|
|
|
108
109
|
register Routes::Capacity
|
|
109
110
|
register Routes::Audit
|
|
110
111
|
register Routes::Metrics
|
|
112
|
+
register Routes::Llm
|
|
111
113
|
|
|
112
114
|
use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
|
|
113
115
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'thor'
|
|
4
|
+
require 'legion/mcp/observer'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module CLI
|
|
8
|
+
class ObserveCommand < Thor
|
|
9
|
+
namespace :observe
|
|
10
|
+
|
|
11
|
+
desc 'stats', 'Show MCP tool usage statistics'
|
|
12
|
+
def stats
|
|
13
|
+
data = Legion::MCP::Observer.stats
|
|
14
|
+
|
|
15
|
+
if options['json']
|
|
16
|
+
puts ::JSON.pretty_generate(serialize_stats(data))
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
puts 'MCP Tool Observation Stats'
|
|
21
|
+
puts '=' * 40
|
|
22
|
+
puts "Total Calls: #{data[:total_calls]}"
|
|
23
|
+
puts "Tools Used: #{data[:tool_count]}"
|
|
24
|
+
puts "Failure Rate: #{(data[:failure_rate] * 100).round(1)}%"
|
|
25
|
+
puts "Since: #{data[:since]&.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
26
|
+
puts
|
|
27
|
+
|
|
28
|
+
return if data[:top_tools].empty?
|
|
29
|
+
|
|
30
|
+
puts 'Top Tools:'
|
|
31
|
+
puts '-' * 60
|
|
32
|
+
puts 'Tool Calls Avg(ms) Fails'
|
|
33
|
+
puts '-' * 60
|
|
34
|
+
data[:top_tools].each do |tool|
|
|
35
|
+
puts format('%-30<name>s %6<calls>d %8<avg>d %6<fails>d',
|
|
36
|
+
name: tool[:name], calls: tool[:call_count],
|
|
37
|
+
avg: tool[:avg_latency_ms], fails: tool[:failure_count])
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
desc 'recent', 'Show recent MCP tool calls'
|
|
42
|
+
method_option :limit, type: :numeric, default: 20, aliases: '-n'
|
|
43
|
+
def recent
|
|
44
|
+
calls = Legion::MCP::Observer.recent(options['limit'] || 20)
|
|
45
|
+
|
|
46
|
+
if options['json']
|
|
47
|
+
puts ::JSON.pretty_generate(calls.map { |c| serialize_call(c) })
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if calls.empty?
|
|
52
|
+
puts 'No recent tool calls recorded.'
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
puts 'Tool Duration Status Time'
|
|
57
|
+
puts '-' * 70
|
|
58
|
+
calls.reverse_each do |call|
|
|
59
|
+
status = call[:success] ? 'OK' : 'FAIL'
|
|
60
|
+
time = call[:timestamp]&.strftime('%H:%M:%S')
|
|
61
|
+
puts format('%-30<tool>s %6<dur>dms %7<st>s %<tm>s',
|
|
62
|
+
tool: call[:tool_name], dur: call[:duration_ms], st: status, tm: time)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
desc 'reset', 'Clear all observation data'
|
|
67
|
+
def reset
|
|
68
|
+
print 'Clear all observation data? (yes/no): '
|
|
69
|
+
return unless $stdin.gets&.strip&.downcase == 'yes'
|
|
70
|
+
|
|
71
|
+
Legion::MCP::Observer.reset!
|
|
72
|
+
puts 'Observation data cleared.'
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def serialize_stats(data)
|
|
78
|
+
{
|
|
79
|
+
total_calls: data[:total_calls],
|
|
80
|
+
tool_count: data[:tool_count],
|
|
81
|
+
failure_rate: data[:failure_rate],
|
|
82
|
+
since: data[:since]&.iso8601,
|
|
83
|
+
top_tools: data[:top_tools].map { |t| t.transform_keys(&:to_s) }
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def serialize_call(call)
|
|
88
|
+
call.transform_keys(&:to_s).tap do |c|
|
|
89
|
+
c['timestamp'] = c['timestamp']&.iso8601 if c['timestamp']
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/legion/cli.rb
CHANGED
|
@@ -43,7 +43,8 @@ module Legion
|
|
|
43
43
|
autoload :Marketplace, 'legion/cli/marketplace_command'
|
|
44
44
|
autoload :Notebook, 'legion/cli/notebook_command'
|
|
45
45
|
autoload :Llm, 'legion/cli/llm_command'
|
|
46
|
-
autoload :Tty,
|
|
46
|
+
autoload :Tty, 'legion/cli/tty_command'
|
|
47
|
+
autoload :ObserveCommand, 'legion/cli/observe_command'
|
|
47
48
|
autoload :Interactive, 'legion/cli/interactive'
|
|
48
49
|
|
|
49
50
|
class Main < Thor
|
|
@@ -241,6 +242,9 @@ module Legion
|
|
|
241
242
|
desc 'tty', 'Rich terminal UI (onboarding, AI chat, dashboard)'
|
|
242
243
|
subcommand 'tty', Legion::CLI::Tty
|
|
243
244
|
|
|
245
|
+
desc 'observe SUBCOMMAND', 'MCP tool observation stats'
|
|
246
|
+
subcommand 'observe', Legion::CLI::ObserveCommand
|
|
247
|
+
|
|
244
248
|
desc 'tree', 'Print a tree of all available commands'
|
|
245
249
|
def tree
|
|
246
250
|
legion_print_command_tree(self.class, 'legion', '')
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module ContextCompiler
|
|
6
|
+
CATEGORIES = {
|
|
7
|
+
tasks: {
|
|
8
|
+
tools: %w[legion.run_task legion.list_tasks legion.get_task legion.delete_task legion.get_task_logs],
|
|
9
|
+
summary: 'Create, list, query, and delete tasks. Run functions via dot-notation task identifiers.'
|
|
10
|
+
},
|
|
11
|
+
chains: {
|
|
12
|
+
tools: %w[legion.list_chains legion.create_chain legion.update_chain legion.delete_chain],
|
|
13
|
+
summary: 'Manage task chains - ordered sequences of tasks that execute in series.'
|
|
14
|
+
},
|
|
15
|
+
relationships: {
|
|
16
|
+
tools: %w[legion.list_relationships legion.create_relationship legion.update_relationship
|
|
17
|
+
legion.delete_relationship],
|
|
18
|
+
summary: 'Manage trigger-action relationships between functions.'
|
|
19
|
+
},
|
|
20
|
+
extensions: {
|
|
21
|
+
tools: %w[legion.list_extensions legion.get_extension legion.enable_extension
|
|
22
|
+
legion.disable_extension],
|
|
23
|
+
summary: 'Manage LEX extensions - list installed, inspect details, enable/disable.'
|
|
24
|
+
},
|
|
25
|
+
schedules: {
|
|
26
|
+
tools: %w[legion.list_schedules legion.create_schedule legion.update_schedule legion.delete_schedule],
|
|
27
|
+
summary: 'Manage scheduled tasks - cron-style recurring task execution.'
|
|
28
|
+
},
|
|
29
|
+
workers: {
|
|
30
|
+
tools: %w[legion.list_workers legion.show_worker legion.worker_lifecycle legion.worker_costs],
|
|
31
|
+
summary: 'Manage digital workers - list, inspect, lifecycle transitions, cost tracking.'
|
|
32
|
+
},
|
|
33
|
+
rbac: {
|
|
34
|
+
tools: %w[legion.rbac_check legion.rbac_assignments legion.rbac_grants],
|
|
35
|
+
summary: 'Role-based access control - check permissions, view assignments and grants.'
|
|
36
|
+
},
|
|
37
|
+
status: {
|
|
38
|
+
tools: %w[legion.get_status legion.get_config legion.team_summary legion.routing_stats],
|
|
39
|
+
summary: 'System status, configuration, team overview, and routing statistics.'
|
|
40
|
+
},
|
|
41
|
+
describe: {
|
|
42
|
+
tools: %w[legion.describe_runner],
|
|
43
|
+
summary: 'Inspect a specific runner function - parameters, return type, metadata.'
|
|
44
|
+
}
|
|
45
|
+
}.freeze
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Returns a compressed summary of all categories with tool counts and tool name lists.
|
|
50
|
+
# @return [Array<Hash>] array of { category:, summary:, tool_count:, tools: }
|
|
51
|
+
def compressed_catalog
|
|
52
|
+
CATEGORIES.map do |category, config|
|
|
53
|
+
tool_names = config[:tools]
|
|
54
|
+
{
|
|
55
|
+
category: category,
|
|
56
|
+
summary: config[:summary],
|
|
57
|
+
tool_count: tool_names.length,
|
|
58
|
+
tools: tool_names
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns tools for a specific category, filtered to only those present in TOOL_CLASSES.
|
|
64
|
+
# @param category_sym [Symbol] one of the CATEGORIES keys
|
|
65
|
+
# @return [Hash, nil] { category:, summary:, tools: [{ name:, description:, params: }] } or nil
|
|
66
|
+
def category_tools(category_sym)
|
|
67
|
+
config = CATEGORIES[category_sym]
|
|
68
|
+
return nil unless config
|
|
69
|
+
|
|
70
|
+
index = tool_index
|
|
71
|
+
tools = config[:tools].filter_map { |name| index[name] }
|
|
72
|
+
return nil if tools.empty?
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
category: category_sym,
|
|
76
|
+
summary: config[:summary],
|
|
77
|
+
tools: tools
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Keyword-match intent against tool names and descriptions.
|
|
82
|
+
# @param intent_string [String] natural language intent
|
|
83
|
+
# @return [Class, nil] best matching tool CLASS from Server::TOOL_CLASSES or nil
|
|
84
|
+
def match_tool(intent_string)
|
|
85
|
+
scored = scored_tools(intent_string)
|
|
86
|
+
return nil if scored.empty?
|
|
87
|
+
|
|
88
|
+
best = scored.max_by { |entry| entry[:score] }
|
|
89
|
+
return nil if best[:score].zero?
|
|
90
|
+
|
|
91
|
+
Server::TOOL_CLASSES.find { |klass| klass.tool_name == best[:name] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Returns top N keyword-matched tools ranked by score.
|
|
95
|
+
# @param intent_string [String] natural language intent
|
|
96
|
+
# @param limit [Integer] max results (default 5)
|
|
97
|
+
# @return [Array<Hash>] array of { name:, description:, score: }
|
|
98
|
+
def match_tools(intent_string, limit: 5)
|
|
99
|
+
scored = scored_tools(intent_string)
|
|
100
|
+
.select { |entry| entry[:score].positive? }
|
|
101
|
+
.sort_by { |entry| -entry[:score] }
|
|
102
|
+
scored.first(limit)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns a hash keyed by tool_name with compressed param info.
|
|
106
|
+
# Memoized — call reset! to clear.
|
|
107
|
+
# @return [Hash<String, Hash>] { name:, description:, params: [String] }
|
|
108
|
+
def tool_index
|
|
109
|
+
@tool_index ||= build_tool_index
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Clears the memoized tool_index.
|
|
113
|
+
def reset!
|
|
114
|
+
@tool_index = nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def build_tool_index
|
|
118
|
+
Server::TOOL_CLASSES.each_with_object({}) do |klass, idx|
|
|
119
|
+
raw_schema = klass.input_schema
|
|
120
|
+
schema = raw_schema.is_a?(Hash) ? raw_schema : raw_schema.to_h
|
|
121
|
+
properties = schema[:properties] || {}
|
|
122
|
+
idx[klass.tool_name] = {
|
|
123
|
+
name: klass.tool_name,
|
|
124
|
+
description: klass.description,
|
|
125
|
+
params: properties.keys.map(&:to_s)
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def scored_tools(intent_string)
|
|
131
|
+
keywords = intent_string.downcase.split
|
|
132
|
+
return [] if keywords.empty?
|
|
133
|
+
|
|
134
|
+
tool_index.values.map do |entry|
|
|
135
|
+
haystack = "#{entry[:name].downcase} #{entry[:description].downcase}"
|
|
136
|
+
score = keywords.count { |kw| haystack.include?(kw) }
|
|
137
|
+
{ name: entry[:name], description: entry[:description], score: score }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'concurrent-ruby'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module MCP
|
|
7
|
+
module Observer
|
|
8
|
+
RING_BUFFER_MAX = 500
|
|
9
|
+
INTENT_BUFFER_MAX = 200
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def record(tool_name:, duration_ms:, success:, params_keys: [], error: nil)
|
|
14
|
+
now = Time.now
|
|
15
|
+
|
|
16
|
+
counters_mutex.synchronize do
|
|
17
|
+
entry = counters[tool_name] || { call_count: 0, total_latency_ms: 0.0, failure_count: 0,
|
|
18
|
+
last_used: nil, last_error: nil }
|
|
19
|
+
counters[tool_name] = {
|
|
20
|
+
call_count: entry[:call_count] + 1,
|
|
21
|
+
total_latency_ms: entry[:total_latency_ms] + duration_ms.to_f,
|
|
22
|
+
failure_count: entry[:failure_count] + (success ? 0 : 1),
|
|
23
|
+
last_used: now,
|
|
24
|
+
last_error: success ? entry[:last_error] : error
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
buffer_mutex.synchronize do
|
|
29
|
+
ring_buffer << {
|
|
30
|
+
tool_name: tool_name,
|
|
31
|
+
duration_ms: duration_ms,
|
|
32
|
+
success: success,
|
|
33
|
+
params_keys: params_keys,
|
|
34
|
+
error: error,
|
|
35
|
+
recorded_at: now
|
|
36
|
+
}
|
|
37
|
+
ring_buffer.shift if ring_buffer.size > RING_BUFFER_MAX
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def record_intent(intent, matched_tool_name)
|
|
42
|
+
intent_mutex.synchronize do
|
|
43
|
+
intent_buffer << { intent: intent, matched_tool: matched_tool_name, recorded_at: Time.now }
|
|
44
|
+
intent_buffer.shift if intent_buffer.size > INTENT_BUFFER_MAX
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def tool_stats(tool_name)
|
|
49
|
+
entry = counters_mutex.synchronize { counters[tool_name] }
|
|
50
|
+
return nil unless entry
|
|
51
|
+
|
|
52
|
+
count = entry[:call_count]
|
|
53
|
+
avg = count.positive? ? (entry[:total_latency_ms] / count).round(2) : 0.0
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
name: tool_name,
|
|
57
|
+
call_count: count,
|
|
58
|
+
avg_latency_ms: avg,
|
|
59
|
+
failure_count: entry[:failure_count],
|
|
60
|
+
last_used: entry[:last_used],
|
|
61
|
+
last_error: entry[:last_error]
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def all_tool_stats
|
|
66
|
+
names = counters_mutex.synchronize { counters.keys.dup }
|
|
67
|
+
names.to_h { |name| [name, tool_stats(name)] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def stats
|
|
71
|
+
all_names = counters_mutex.synchronize { counters.keys.dup }
|
|
72
|
+
total = all_names.sum { |n| counters_mutex.synchronize { counters[n][:call_count] } }
|
|
73
|
+
failures = all_names.sum { |n| counters_mutex.synchronize { counters[n][:failure_count] } }
|
|
74
|
+
rate = total.positive? ? (failures.to_f / total).round(4) : 0.0
|
|
75
|
+
|
|
76
|
+
top = all_names
|
|
77
|
+
.map { |n| tool_stats(n) }
|
|
78
|
+
.sort_by { |s| -s[:call_count] }
|
|
79
|
+
.first(10)
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
total_calls: total,
|
|
83
|
+
tool_count: all_names.size,
|
|
84
|
+
failure_rate: rate,
|
|
85
|
+
top_tools: top,
|
|
86
|
+
since: started_at
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def recent(limit = 10)
|
|
91
|
+
buffer_mutex.synchronize { ring_buffer.last(limit) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def recent_intents(limit = 10)
|
|
95
|
+
intent_mutex.synchronize { intent_buffer.last(limit) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def reset!
|
|
99
|
+
counters_mutex.synchronize { counters.clear }
|
|
100
|
+
buffer_mutex.synchronize { ring_buffer.clear }
|
|
101
|
+
intent_mutex.synchronize { intent_buffer.clear }
|
|
102
|
+
@started_at = Time.now
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Internal state accessors
|
|
106
|
+
def counters
|
|
107
|
+
@counters ||= {}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def counters_mutex
|
|
111
|
+
@counters_mutex ||= Mutex.new
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def ring_buffer
|
|
115
|
+
@ring_buffer ||= []
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def buffer_mutex
|
|
119
|
+
@buffer_mutex ||= Mutex.new
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def intent_buffer
|
|
123
|
+
@intent_buffer ||= []
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def intent_mutex
|
|
127
|
+
@intent_mutex ||= Mutex.new
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def started_at
|
|
131
|
+
@started_at ||= Time.now
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/legion/mcp/server.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'observer'
|
|
4
|
+
require_relative 'usage_filter'
|
|
3
5
|
require_relative 'tools/run_task'
|
|
4
6
|
require_relative 'tools/describe_runner'
|
|
5
7
|
require_relative 'tools/list_tasks'
|
|
@@ -33,6 +35,9 @@ require_relative 'tools/routing_stats'
|
|
|
33
35
|
require_relative 'tools/rbac_check'
|
|
34
36
|
require_relative 'tools/rbac_assignments'
|
|
35
37
|
require_relative 'tools/rbac_grants'
|
|
38
|
+
require_relative 'context_compiler'
|
|
39
|
+
require_relative 'tools/do_action'
|
|
40
|
+
require_relative 'tools/discover_tools'
|
|
36
41
|
require_relative 'resources/runner_catalog'
|
|
37
42
|
require_relative 'resources/extension_info'
|
|
38
43
|
|
|
@@ -72,7 +77,9 @@ module Legion
|
|
|
72
77
|
Tools::RoutingStats,
|
|
73
78
|
Tools::RbacCheck,
|
|
74
79
|
Tools::RbacAssignments,
|
|
75
|
-
Tools::RbacGrants
|
|
80
|
+
Tools::RbacGrants,
|
|
81
|
+
Tools::DoAction,
|
|
82
|
+
Tools::DiscoverTools
|
|
76
83
|
].freeze
|
|
77
84
|
|
|
78
85
|
class << self
|
|
@@ -92,12 +99,44 @@ module Legion
|
|
|
92
99
|
resource_templates: Resources::ExtensionInfo.resource_templates
|
|
93
100
|
)
|
|
94
101
|
|
|
102
|
+
if defined?(Observer)
|
|
103
|
+
::MCP.configure do |c|
|
|
104
|
+
c.instrumentation_callback = ->(idata) { Server.wire_observer(idata) }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
server.tools_list_handler do |_params|
|
|
109
|
+
build_filtered_tool_list.map(&:to_h)
|
|
110
|
+
end
|
|
111
|
+
|
|
95
112
|
Resources::RunnerCatalog.register(server)
|
|
96
113
|
Resources::ExtensionInfo.register_read_handler(server)
|
|
97
114
|
|
|
98
115
|
server
|
|
99
116
|
end
|
|
100
117
|
|
|
118
|
+
def wire_observer(data)
|
|
119
|
+
return unless data[:method] == 'tools/call' && data[:tool_name]
|
|
120
|
+
|
|
121
|
+
duration_ms = (data[:duration].to_f * 1000).to_i
|
|
122
|
+
params_keys = data[:tool_arguments].respond_to?(:keys) ? data[:tool_arguments].keys : []
|
|
123
|
+
success = data[:error].nil?
|
|
124
|
+
|
|
125
|
+
Observer.record(
|
|
126
|
+
tool_name: data[:tool_name],
|
|
127
|
+
duration_ms: duration_ms,
|
|
128
|
+
success: success,
|
|
129
|
+
params_keys: params_keys,
|
|
130
|
+
error: data[:error]
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_filtered_tool_list(keywords: [])
|
|
135
|
+
tool_names = TOOL_CLASSES.map { |tc| tc.respond_to?(:tool_name) ? tc.tool_name : tc.name }
|
|
136
|
+
ranked = UsageFilter.ranked_tools(tool_names, keywords: keywords)
|
|
137
|
+
ranked.filter_map { |name| TOOL_CLASSES.find { |tc| (tc.respond_to?(:tool_name) ? tc.tool_name : tc.name) == name } }
|
|
138
|
+
end
|
|
139
|
+
|
|
101
140
|
private
|
|
102
141
|
|
|
103
142
|
def instructions
|