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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f924192ddb882e9dd08fa0ada9088c4f9f468ef8d782df7a06888774ebe5b9d
4
- data.tar.gz: f7eab58ceaf4f9119dad859f9b5d47945b57854a2e65e7f68484660da9c2f3a9
3
+ metadata.gz: d9c2f9048a25ea9cff5f0b8db43aaaf49ab40bb6c388b2f892c3f952fb7233f4
4
+ data.tar.gz: 02225b7029518b45f1d78f632cce4fdbaee631efb9c03cade5e575ac3c96e3cf
5
5
  SHA512:
6
- metadata.gz: 5b120a8843d8626da5508cfef24fa2791539c135d090a83a1519e9db99d3fb78aac7b52fde48b2c844435687114dada9b9622d73f6ab204607e73f422f2a9b90
7
- data.tar.gz: 6965a20d9389f2b8e16be7e83fdac2835c6b18d47269bb9bf9f102588d03ed7df72b62cc119afeb1f7b0c29695efd0654e60af093bff4e00d3e2eefc76f76723
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.67
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. 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_llm (legion-llm, optional)
49
- ├── 8. setup_supervision (process supervision)
50
- ├── 9. load_extensions (discover + load LEX gems, filtered by role profile)
51
- ├── 10. Legion::Crypt.cs (distribute cluster secret)
52
- └── 11. setup_api (start Sinatra/Puma on port 4567)
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 # 1379 examples, 0 failures
727
- bundle exec rubocop # 396 files, 0 offenses
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, 'legion/cli/tty_command'
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
@@ -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