legion-mcp 0.1.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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +16 -0
  3. data/.gitignore +6 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +40 -0
  6. data/CHANGELOG.md +20 -0
  7. data/CLAUDE.md +101 -0
  8. data/Gemfile +10 -0
  9. data/LICENSE +167 -0
  10. data/README.md +182 -0
  11. data/Rakefile +5 -0
  12. data/legion-mcp.gemspec +35 -0
  13. data/lib/legion/mcp/auth.rb +50 -0
  14. data/lib/legion/mcp/context_compiler.rb +173 -0
  15. data/lib/legion/mcp/context_guard.rb +105 -0
  16. data/lib/legion/mcp/embedding_index.rb +113 -0
  17. data/lib/legion/mcp/observer.rb +171 -0
  18. data/lib/legion/mcp/pattern_store.rb +303 -0
  19. data/lib/legion/mcp/resources/extension_info.rb +67 -0
  20. data/lib/legion/mcp/resources/runner_catalog.rb +63 -0
  21. data/lib/legion/mcp/server.rb +178 -0
  22. data/lib/legion/mcp/tier_router.rb +122 -0
  23. data/lib/legion/mcp/tool_governance.rb +77 -0
  24. data/lib/legion/mcp/tools/create_chain.rb +50 -0
  25. data/lib/legion/mcp/tools/create_relationship.rb +51 -0
  26. data/lib/legion/mcp/tools/create_schedule.rb +64 -0
  27. data/lib/legion/mcp/tools/delete_chain.rb +52 -0
  28. data/lib/legion/mcp/tools/delete_relationship.rb +52 -0
  29. data/lib/legion/mcp/tools/delete_schedule.rb +52 -0
  30. data/lib/legion/mcp/tools/delete_task.rb +49 -0
  31. data/lib/legion/mcp/tools/describe_runner.rb +92 -0
  32. data/lib/legion/mcp/tools/disable_extension.rb +50 -0
  33. data/lib/legion/mcp/tools/discover_tools.rb +53 -0
  34. data/lib/legion/mcp/tools/do_action.rb +85 -0
  35. data/lib/legion/mcp/tools/enable_extension.rb +50 -0
  36. data/lib/legion/mcp/tools/get_config.rb +63 -0
  37. data/lib/legion/mcp/tools/get_extension.rb +56 -0
  38. data/lib/legion/mcp/tools/get_status.rb +50 -0
  39. data/lib/legion/mcp/tools/get_task.rb +48 -0
  40. data/lib/legion/mcp/tools/get_task_logs.rb +56 -0
  41. data/lib/legion/mcp/tools/list_chains.rb +48 -0
  42. data/lib/legion/mcp/tools/list_extensions.rb +46 -0
  43. data/lib/legion/mcp/tools/list_relationships.rb +45 -0
  44. data/lib/legion/mcp/tools/list_schedules.rb +51 -0
  45. data/lib/legion/mcp/tools/list_tasks.rb +50 -0
  46. data/lib/legion/mcp/tools/list_workers.rb +54 -0
  47. data/lib/legion/mcp/tools/rbac_assignments.rb +45 -0
  48. data/lib/legion/mcp/tools/rbac_check.rb +46 -0
  49. data/lib/legion/mcp/tools/rbac_grants.rb +41 -0
  50. data/lib/legion/mcp/tools/routing_stats.rb +51 -0
  51. data/lib/legion/mcp/tools/run_task.rb +68 -0
  52. data/lib/legion/mcp/tools/show_worker.rb +48 -0
  53. data/lib/legion/mcp/tools/team_summary.rb +55 -0
  54. data/lib/legion/mcp/tools/update_chain.rb +54 -0
  55. data/lib/legion/mcp/tools/update_relationship.rb +55 -0
  56. data/lib/legion/mcp/tools/update_schedule.rb +65 -0
  57. data/lib/legion/mcp/tools/worker_costs.rb +55 -0
  58. data/lib/legion/mcp/tools/worker_lifecycle.rb +54 -0
  59. data/lib/legion/mcp/usage_filter.rb +86 -0
  60. data/lib/legion/mcp/version.rb +7 -0
  61. data/lib/legion/mcp.rb +30 -0
  62. metadata +195 -0
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'embedding_index'
4
+
5
+ module Legion
6
+ module MCP
7
+ module ContextCompiler
8
+ CATEGORIES = {
9
+ tasks: {
10
+ tools: %w[legion.run_task legion.list_tasks legion.get_task legion.delete_task legion.get_task_logs],
11
+ summary: 'Create, list, query, and delete tasks. Run functions via dot-notation task identifiers.'
12
+ },
13
+ chains: {
14
+ tools: %w[legion.list_chains legion.create_chain legion.update_chain legion.delete_chain],
15
+ summary: 'Manage task chains - ordered sequences of tasks that execute in series.'
16
+ },
17
+ relationships: {
18
+ tools: %w[legion.list_relationships legion.create_relationship legion.update_relationship
19
+ legion.delete_relationship],
20
+ summary: 'Manage trigger-action relationships between functions.'
21
+ },
22
+ extensions: {
23
+ tools: %w[legion.list_extensions legion.get_extension legion.enable_extension
24
+ legion.disable_extension],
25
+ summary: 'Manage LEX extensions - list installed, inspect details, enable/disable.'
26
+ },
27
+ schedules: {
28
+ tools: %w[legion.list_schedules legion.create_schedule legion.update_schedule legion.delete_schedule],
29
+ summary: 'Manage scheduled tasks - cron-style recurring task execution.'
30
+ },
31
+ workers: {
32
+ tools: %w[legion.list_workers legion.show_worker legion.worker_lifecycle legion.worker_costs],
33
+ summary: 'Manage digital workers - list, inspect, lifecycle transitions, cost tracking.'
34
+ },
35
+ rbac: {
36
+ tools: %w[legion.rbac_check legion.rbac_assignments legion.rbac_grants],
37
+ summary: 'Role-based access control - check permissions, view assignments and grants.'
38
+ },
39
+ status: {
40
+ tools: %w[legion.get_status legion.get_config legion.team_summary legion.routing_stats],
41
+ summary: 'System status, configuration, team overview, and routing statistics.'
42
+ },
43
+ describe: {
44
+ tools: %w[legion.describe_runner],
45
+ summary: 'Inspect a specific runner function - parameters, return type, metadata.'
46
+ }
47
+ }.freeze
48
+
49
+ module_function
50
+
51
+ # Returns a compressed summary of all categories with tool counts and tool name lists.
52
+ # @return [Array<Hash>] array of { category:, summary:, tool_count:, tools: }
53
+ def compressed_catalog
54
+ CATEGORIES.map do |category, config|
55
+ tool_names = config[:tools]
56
+ {
57
+ category: category,
58
+ summary: config[:summary],
59
+ tool_count: tool_names.length,
60
+ tools: tool_names
61
+ }
62
+ end
63
+ end
64
+
65
+ # Returns tools for a specific category, filtered to only those present in TOOL_CLASSES.
66
+ # @param category_sym [Symbol] one of the CATEGORIES keys
67
+ # @return [Hash, nil] { category:, summary:, tools: [{ name:, description:, params: }] } or nil
68
+ def category_tools(category_sym)
69
+ config = CATEGORIES[category_sym]
70
+ return nil unless config
71
+
72
+ index = tool_index
73
+ tools = config[:tools].filter_map { |name| index[name] }
74
+ return nil if tools.empty?
75
+
76
+ {
77
+ category: category_sym,
78
+ summary: config[:summary],
79
+ tools: tools
80
+ }
81
+ end
82
+
83
+ # Keyword-match intent against tool names and descriptions.
84
+ # @param intent_string [String] natural language intent
85
+ # @return [Class, nil] best matching tool CLASS from Server::TOOL_CLASSES or nil
86
+ def match_tool(intent_string)
87
+ scored = scored_tools(intent_string)
88
+ return nil if scored.empty?
89
+
90
+ best = scored.max_by { |entry| entry[:score] }
91
+ return nil if best[:score].zero?
92
+
93
+ Server::TOOL_CLASSES.find { |klass| klass.tool_name == best[:name] }
94
+ end
95
+
96
+ # Returns top N keyword-matched tools ranked by score.
97
+ # @param intent_string [String] natural language intent
98
+ # @param limit [Integer] max results (default 5)
99
+ # @return [Array<Hash>] array of { name:, description:, score: }
100
+ def match_tools(intent_string, limit: 5)
101
+ scored = scored_tools(intent_string)
102
+ .select { |entry| entry[:score].positive? }
103
+ .sort_by { |entry| -entry[:score] }
104
+ scored.first(limit)
105
+ end
106
+
107
+ # Returns a hash keyed by tool_name with compressed param info.
108
+ # Memoized — call reset! to clear.
109
+ # @return [Hash<String, Hash>] { name:, description:, params: [String] }
110
+ def tool_index
111
+ @tool_index ||= build_tool_index
112
+ end
113
+
114
+ # Clears the memoized tool_index.
115
+ def reset!
116
+ @tool_index = nil
117
+ Legion::MCP::EmbeddingIndex.reset! if defined?(Legion::MCP::EmbeddingIndex)
118
+ end
119
+
120
+ def build_tool_index
121
+ Server::TOOL_CLASSES.each_with_object({}) do |klass, idx|
122
+ raw_schema = klass.input_schema
123
+ schema = raw_schema.is_a?(Hash) ? raw_schema : raw_schema.to_h
124
+ properties = schema[:properties] || {}
125
+ idx[klass.tool_name] = {
126
+ name: klass.tool_name,
127
+ description: klass.description,
128
+ params: properties.keys.map(&:to_s)
129
+ }
130
+ end
131
+ end
132
+
133
+ def scored_tools(intent_string)
134
+ keywords = intent_string.downcase.split
135
+ return [] if keywords.empty?
136
+
137
+ kw_scores = keyword_score_map(keywords)
138
+ sem_scores = semantic_score_map(intent_string)
139
+ use_semantic = !sem_scores.empty?
140
+
141
+ tool_index.values.map do |entry|
142
+ kw_raw = kw_scores[entry[:name]] || 0
143
+ if use_semantic
144
+ max_kw = kw_scores.values.max || 1
145
+ normalized_kw = max_kw.positive? ? kw_raw.to_f / max_kw : 0.0
146
+ sem = sem_scores[entry[:name]] || 0.0
147
+ blended = (normalized_kw * 0.4) + (sem * 0.6)
148
+ else
149
+ blended = kw_raw.to_f
150
+ end
151
+
152
+ { name: entry[:name], description: entry[:description], score: blended }
153
+ end
154
+ end
155
+
156
+ def keyword_score_map(keywords)
157
+ tool_index.values.to_h do |entry|
158
+ haystack = "#{entry[:name].downcase} #{entry[:description].downcase}"
159
+ score = keywords.count { |kw| haystack.include?(kw) }
160
+ [entry[:name], score]
161
+ end
162
+ end
163
+
164
+ def semantic_score_map(intent_string)
165
+ return {} unless defined?(Legion::MCP::EmbeddingIndex) && Legion::MCP::EmbeddingIndex.populated?
166
+
167
+ Legion::MCP::EmbeddingIndex.semantic_match(intent_string, limit: tool_index.size).to_h do |result|
168
+ [result[:name], result[:score]]
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module ContextGuard
6
+ DEFAULT_MAX_STALE_SECONDS = 3600
7
+ DEFAULT_RAPID_FIRE_THRESHOLD = 5
8
+ DEFAULT_RAPID_FIRE_WINDOW_SECS = 600
9
+ DEFAULT_ANOMALY_MISS_THRESHOLD = 2
10
+
11
+ module_function
12
+
13
+ def check(pattern, _params, _context)
14
+ return staleness_failure(pattern) if stale?(pattern)
15
+ return anomaly_failure(pattern) if anomalous?(pattern)
16
+ return rapid_fire_failure(pattern) if rapid_fire?(pattern[:intent_hash])
17
+
18
+ { passed: true }
19
+ end
20
+
21
+ def record_request(intent_hash)
22
+ mutex.synchronize do
23
+ requests[intent_hash] ||= []
24
+ requests[intent_hash] << Time.now
25
+ end
26
+ end
27
+
28
+ def reset!
29
+ mutex.synchronize { requests.clear }
30
+ end
31
+
32
+ def stale?(pattern)
33
+ last_hit = pattern[:last_hit_at]
34
+ return false unless last_hit
35
+
36
+ (Time.now - last_hit) > max_stale_seconds
37
+ end
38
+
39
+ def anomalous?(pattern)
40
+ (pattern[:miss_count] || 0) >= anomaly_miss_threshold
41
+ end
42
+
43
+ def rapid_fire?(intent_hash)
44
+ return false unless intent_hash
45
+
46
+ window = Time.now - rapid_fire_window_seconds
47
+ count = mutex.synchronize do
48
+ entries = requests[intent_hash]
49
+ return false unless entries
50
+
51
+ entries.reject! { |t| t < window }
52
+ entries.size
53
+ end
54
+
55
+ count > rapid_fire_threshold
56
+ end
57
+
58
+ def max_stale_seconds
59
+ setting(:max_stale_seconds) || DEFAULT_MAX_STALE_SECONDS
60
+ end
61
+
62
+ def rapid_fire_threshold
63
+ setting(:rapid_fire_threshold) || DEFAULT_RAPID_FIRE_THRESHOLD
64
+ end
65
+
66
+ def rapid_fire_window_seconds
67
+ setting(:rapid_fire_window_seconds) || DEFAULT_RAPID_FIRE_WINDOW_SECS
68
+ end
69
+
70
+ def anomaly_miss_threshold
71
+ DEFAULT_ANOMALY_MISS_THRESHOLD
72
+ end
73
+
74
+ def setting(key)
75
+ return nil unless defined?(Legion::Settings)
76
+
77
+ Legion::Settings.dig(:mcp, :tier0, :guards, key)
78
+ rescue StandardError
79
+ nil
80
+ end
81
+
82
+ def staleness_failure(pattern)
83
+ age = pattern[:last_hit_at] ? (Time.now - pattern[:last_hit_at]).round(0) : 0
84
+ { passed: false, guard: :staleness, reason: "pattern stale (#{age}s since last hit)" }
85
+ end
86
+
87
+ def anomaly_failure(pattern)
88
+ { passed: false, guard: :anomaly, reason: "#{pattern[:miss_count]} consecutive misses" }
89
+ end
90
+
91
+ def rapid_fire_failure(_pattern)
92
+ { passed: false, guard: :rapid_fire,
93
+ reason: "exceeded #{rapid_fire_threshold} requests in #{rapid_fire_window_seconds}s" }
94
+ end
95
+
96
+ def requests
97
+ @requests ||= {}
98
+ end
99
+
100
+ def mutex
101
+ @mutex ||= Mutex.new
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module EmbeddingIndex
6
+ module_function
7
+
8
+ def build_from_tool_data(tool_data, embedder: default_embedder)
9
+ @embedder = embedder
10
+ mutex.synchronize do
11
+ tool_data.each do |tool|
12
+ composite = build_composite(tool[:name], tool[:description], tool[:params])
13
+ vector = safe_embed(composite, embedder)
14
+ next unless vector
15
+
16
+ index[tool[:name]] = {
17
+ name: tool[:name],
18
+ composite_text: composite,
19
+ vector: vector,
20
+ built_at: Time.now
21
+ }
22
+ end
23
+ end
24
+ end
25
+
26
+ def semantic_match(intent, embedder: @embedder || default_embedder, limit: 5)
27
+ return [] if index.empty?
28
+
29
+ intent_vec = safe_embed(intent, embedder)
30
+ return [] unless intent_vec
31
+
32
+ scores = mutex.synchronize do
33
+ index.values.filter_map do |entry|
34
+ next unless entry[:vector]
35
+
36
+ score = cosine_similarity(intent_vec, entry[:vector])
37
+ { name: entry[:name], score: score }
38
+ end
39
+ end
40
+
41
+ scores.sort_by { |s| -s[:score] }.first(limit)
42
+ end
43
+
44
+ def cosine_similarity(vec_a, vec_b)
45
+ dot = vec_a.zip(vec_b).sum { |a, b| a * b }
46
+ mag_a = Math.sqrt(vec_a.sum { |x| x**2 })
47
+ mag_b = Math.sqrt(vec_b.sum { |x| x**2 })
48
+ return 0.0 if mag_a.zero? || mag_b.zero?
49
+
50
+ dot / (mag_a * mag_b)
51
+ end
52
+
53
+ def entry(tool_name)
54
+ mutex.synchronize { index[tool_name] }
55
+ end
56
+
57
+ def size
58
+ mutex.synchronize { index.size }
59
+ end
60
+
61
+ def populated?
62
+ mutex.synchronize { !index.empty? }
63
+ end
64
+
65
+ def coverage
66
+ mutex.synchronize do
67
+ return 0.0 if index.empty?
68
+
69
+ with_vectors = index.values.count { |e| e[:vector] }
70
+ with_vectors.to_f / index.size
71
+ end
72
+ end
73
+
74
+ def reset!
75
+ @embedder = nil
76
+ mutex.synchronize { index.clear }
77
+ end
78
+
79
+ def index
80
+ @index ||= {}
81
+ end
82
+
83
+ def mutex
84
+ @mutex ||= Mutex.new
85
+ end
86
+
87
+ def build_composite(name, description, params)
88
+ parts = [name, '--', description]
89
+ parts << "Params: #{params.join(', ')}" unless params.empty?
90
+ parts.join(' ')
91
+ end
92
+
93
+ def safe_embed(text, embedder)
94
+ return nil unless embedder
95
+
96
+ result = embedder.call(text)
97
+ return nil unless result.is_a?(Array) && !result.empty?
98
+
99
+ result
100
+ rescue StandardError
101
+ nil
102
+ end
103
+
104
+ def default_embedder
105
+ return nil unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
106
+
107
+ ->(text) { Legion::LLM.embed(text)[:vector] }
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+ require 'digest'
5
+
6
+ module Legion
7
+ module MCP
8
+ module Observer
9
+ RING_BUFFER_MAX = 500
10
+ INTENT_BUFFER_MAX = 200
11
+
12
+ module_function
13
+
14
+ def record(tool_name:, duration_ms:, success:, params_keys: [], error: nil)
15
+ now = Time.now
16
+
17
+ counters_mutex.synchronize do
18
+ entry = counters[tool_name] || { call_count: 0, total_latency_ms: 0.0, failure_count: 0,
19
+ last_used: nil, last_error: nil }
20
+ counters[tool_name] = {
21
+ call_count: entry[:call_count] + 1,
22
+ total_latency_ms: entry[:total_latency_ms] + duration_ms.to_f,
23
+ failure_count: entry[:failure_count] + (success ? 0 : 1),
24
+ last_used: now,
25
+ last_error: success ? entry[:last_error] : error
26
+ }
27
+ end
28
+
29
+ buffer_mutex.synchronize do
30
+ ring_buffer << {
31
+ tool_name: tool_name,
32
+ duration_ms: duration_ms,
33
+ success: success,
34
+ params_keys: params_keys,
35
+ error: error,
36
+ recorded_at: now
37
+ }
38
+ ring_buffer.shift if ring_buffer.size > RING_BUFFER_MAX
39
+ end
40
+ end
41
+
42
+ def record_intent(intent, matched_tool_name)
43
+ intent_mutex.synchronize do
44
+ intent_buffer << { intent: intent, matched_tool: matched_tool_name, recorded_at: Time.now }
45
+ intent_buffer.shift if intent_buffer.size > INTENT_BUFFER_MAX
46
+ end
47
+ end
48
+
49
+ def record_intent_with_result(intent:, tool_name:, success:)
50
+ record_intent(intent, tool_name)
51
+ return unless success
52
+ return unless defined?(Legion::MCP::PatternStore)
53
+
54
+ normalized = intent.to_s.strip.downcase.gsub(/\s+/, ' ')
55
+ intent_hash = Digest::SHA256.hexdigest(normalized)
56
+
57
+ promotion = Legion::MCP::PatternStore.record_candidate(
58
+ intent_hash: intent_hash,
59
+ tool_chain: [tool_name],
60
+ intent_text: intent
61
+ )
62
+
63
+ return unless promotion&.dig(:promote)
64
+
65
+ Legion::MCP::PatternStore.promote_candidate(
66
+ intent_hash: promotion[:intent_hash],
67
+ tool_chain: promotion[:tool_chain],
68
+ intent_text: promotion[:intent_text],
69
+ intent_vector: try_embed(normalized)
70
+ )
71
+ end
72
+
73
+ def tool_stats(tool_name)
74
+ entry = counters_mutex.synchronize { counters[tool_name] }
75
+ return nil unless entry
76
+
77
+ count = entry[:call_count]
78
+ avg = count.positive? ? (entry[:total_latency_ms] / count).round(2) : 0.0
79
+
80
+ {
81
+ name: tool_name,
82
+ call_count: count,
83
+ avg_latency_ms: avg,
84
+ failure_count: entry[:failure_count],
85
+ last_used: entry[:last_used],
86
+ last_error: entry[:last_error]
87
+ }
88
+ end
89
+
90
+ def all_tool_stats
91
+ names = counters_mutex.synchronize { counters.keys.dup }
92
+ names.to_h { |name| [name, tool_stats(name)] }
93
+ end
94
+
95
+ def stats
96
+ all_names = counters_mutex.synchronize { counters.keys.dup }
97
+ total = all_names.sum { |n| counters_mutex.synchronize { counters[n][:call_count] } }
98
+ failures = all_names.sum { |n| counters_mutex.synchronize { counters[n][:failure_count] } }
99
+ rate = total.positive? ? (failures.to_f / total).round(4) : 0.0
100
+
101
+ top = all_names
102
+ .map { |n| tool_stats(n) }
103
+ .sort_by { |s| -s[:call_count] }
104
+ .first(10)
105
+
106
+ {
107
+ total_calls: total,
108
+ tool_count: all_names.size,
109
+ failure_rate: rate,
110
+ top_tools: top,
111
+ since: started_at
112
+ }
113
+ end
114
+
115
+ def recent(limit = 10)
116
+ buffer_mutex.synchronize { ring_buffer.last(limit) }
117
+ end
118
+
119
+ def recent_intents(limit = 10)
120
+ intent_mutex.synchronize { intent_buffer.last(limit) }
121
+ end
122
+
123
+ def reset!
124
+ counters_mutex.synchronize { counters.clear }
125
+ buffer_mutex.synchronize { ring_buffer.clear }
126
+ intent_mutex.synchronize { intent_buffer.clear }
127
+ @started_at = Time.now
128
+ end
129
+
130
+ # Internal state accessors
131
+ def counters
132
+ @counters ||= {}
133
+ end
134
+
135
+ def counters_mutex
136
+ @counters_mutex ||= Mutex.new
137
+ end
138
+
139
+ def ring_buffer
140
+ @ring_buffer ||= []
141
+ end
142
+
143
+ def buffer_mutex
144
+ @buffer_mutex ||= Mutex.new
145
+ end
146
+
147
+ def intent_buffer
148
+ @intent_buffer ||= []
149
+ end
150
+
151
+ def intent_mutex
152
+ @intent_mutex ||= Mutex.new
153
+ end
154
+
155
+ def started_at
156
+ @started_at ||= Time.now
157
+ end
158
+
159
+ def try_embed(text)
160
+ return nil unless defined?(Legion::MCP::EmbeddingIndex)
161
+
162
+ embedder = Legion::MCP::EmbeddingIndex.instance_variable_get(:@embedder)
163
+ return nil unless embedder
164
+
165
+ embedder.call(text)
166
+ rescue StandardError
167
+ nil
168
+ end
169
+ end
170
+ end
171
+ end