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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +6 -0
- data/.rspec +3 -0
- data/.rubocop.yml +40 -0
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +101 -0
- data/Gemfile +10 -0
- data/LICENSE +167 -0
- data/README.md +182 -0
- data/Rakefile +5 -0
- data/legion-mcp.gemspec +35 -0
- data/lib/legion/mcp/auth.rb +50 -0
- data/lib/legion/mcp/context_compiler.rb +173 -0
- data/lib/legion/mcp/context_guard.rb +105 -0
- data/lib/legion/mcp/embedding_index.rb +113 -0
- data/lib/legion/mcp/observer.rb +171 -0
- data/lib/legion/mcp/pattern_store.rb +303 -0
- data/lib/legion/mcp/resources/extension_info.rb +67 -0
- data/lib/legion/mcp/resources/runner_catalog.rb +63 -0
- data/lib/legion/mcp/server.rb +178 -0
- data/lib/legion/mcp/tier_router.rb +122 -0
- data/lib/legion/mcp/tool_governance.rb +77 -0
- data/lib/legion/mcp/tools/create_chain.rb +50 -0
- data/lib/legion/mcp/tools/create_relationship.rb +51 -0
- data/lib/legion/mcp/tools/create_schedule.rb +64 -0
- data/lib/legion/mcp/tools/delete_chain.rb +52 -0
- data/lib/legion/mcp/tools/delete_relationship.rb +52 -0
- data/lib/legion/mcp/tools/delete_schedule.rb +52 -0
- data/lib/legion/mcp/tools/delete_task.rb +49 -0
- data/lib/legion/mcp/tools/describe_runner.rb +92 -0
- data/lib/legion/mcp/tools/disable_extension.rb +50 -0
- data/lib/legion/mcp/tools/discover_tools.rb +53 -0
- data/lib/legion/mcp/tools/do_action.rb +85 -0
- data/lib/legion/mcp/tools/enable_extension.rb +50 -0
- data/lib/legion/mcp/tools/get_config.rb +63 -0
- data/lib/legion/mcp/tools/get_extension.rb +56 -0
- data/lib/legion/mcp/tools/get_status.rb +50 -0
- data/lib/legion/mcp/tools/get_task.rb +48 -0
- data/lib/legion/mcp/tools/get_task_logs.rb +56 -0
- data/lib/legion/mcp/tools/list_chains.rb +48 -0
- data/lib/legion/mcp/tools/list_extensions.rb +46 -0
- data/lib/legion/mcp/tools/list_relationships.rb +45 -0
- data/lib/legion/mcp/tools/list_schedules.rb +51 -0
- data/lib/legion/mcp/tools/list_tasks.rb +50 -0
- data/lib/legion/mcp/tools/list_workers.rb +54 -0
- data/lib/legion/mcp/tools/rbac_assignments.rb +45 -0
- data/lib/legion/mcp/tools/rbac_check.rb +46 -0
- data/lib/legion/mcp/tools/rbac_grants.rb +41 -0
- data/lib/legion/mcp/tools/routing_stats.rb +51 -0
- data/lib/legion/mcp/tools/run_task.rb +68 -0
- data/lib/legion/mcp/tools/show_worker.rb +48 -0
- data/lib/legion/mcp/tools/team_summary.rb +55 -0
- data/lib/legion/mcp/tools/update_chain.rb +54 -0
- data/lib/legion/mcp/tools/update_relationship.rb +55 -0
- data/lib/legion/mcp/tools/update_schedule.rb +65 -0
- data/lib/legion/mcp/tools/worker_costs.rb +55 -0
- data/lib/legion/mcp/tools/worker_lifecycle.rb +54 -0
- data/lib/legion/mcp/usage_filter.rb +86 -0
- data/lib/legion/mcp/version.rb +7 -0
- data/lib/legion/mcp.rb +30 -0
- metadata +195 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module MCP
|
|
7
|
+
module PatternStore
|
|
8
|
+
CONFIDENCE_SUCCESS_DELTA = 0.02
|
|
9
|
+
CONFIDENCE_FAILURE_DELTA = -0.05
|
|
10
|
+
SEEDED_CONFIDENCE = 0.5
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def store(pattern)
|
|
15
|
+
hash = pattern[:intent_hash]
|
|
16
|
+
mutex.synchronize { patterns_l0[hash] = pattern.dup }
|
|
17
|
+
persist_l1(hash, pattern)
|
|
18
|
+
persist_l2(hash, pattern)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def lookup(intent_hash)
|
|
22
|
+
result = mutex.synchronize { patterns_l0[intent_hash]&.dup }
|
|
23
|
+
return result if result
|
|
24
|
+
|
|
25
|
+
result = lookup_l1(intent_hash)
|
|
26
|
+
if result
|
|
27
|
+
mutex.synchronize { patterns_l0[intent_hash] = result }
|
|
28
|
+
return result.dup
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result = lookup_l2(intent_hash)
|
|
32
|
+
if result
|
|
33
|
+
mutex.synchronize { patterns_l0[intent_hash] = result }
|
|
34
|
+
persist_l1(intent_hash, result)
|
|
35
|
+
return result.dup
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def lookup_semantic(intent_vector, threshold: 0.85)
|
|
42
|
+
return nil unless intent_vector && !patterns_l0.empty?
|
|
43
|
+
|
|
44
|
+
best_hash = nil
|
|
45
|
+
best_score = 0.0
|
|
46
|
+
|
|
47
|
+
mutex.synchronize do
|
|
48
|
+
patterns_l0.each do |hash, pattern|
|
|
49
|
+
next unless pattern[:intent_vector]
|
|
50
|
+
|
|
51
|
+
score = cosine_similarity(intent_vector, pattern[:intent_vector])
|
|
52
|
+
if score > best_score && score >= threshold
|
|
53
|
+
best_score = score
|
|
54
|
+
best_hash = hash
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
best_hash ? lookup(best_hash) : nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def record_hit(intent_hash)
|
|
63
|
+
mutex.synchronize do
|
|
64
|
+
pattern = patterns_l0[intent_hash]
|
|
65
|
+
return unless pattern
|
|
66
|
+
|
|
67
|
+
pattern[:hit_count] = (pattern[:hit_count] || 0) + 1
|
|
68
|
+
pattern[:miss_count] = 0
|
|
69
|
+
pattern[:last_hit_at] = Time.now
|
|
70
|
+
pattern[:confidence] = (pattern[:confidence] + CONFIDENCE_SUCCESS_DELTA).clamp(0.0, 1.0)
|
|
71
|
+
end
|
|
72
|
+
sync_to_persistence(intent_hash)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def record_miss(intent_hash)
|
|
76
|
+
mutex.synchronize do
|
|
77
|
+
pattern = patterns_l0[intent_hash]
|
|
78
|
+
return unless pattern
|
|
79
|
+
|
|
80
|
+
pattern[:miss_count] = (pattern[:miss_count] || 0) + 1
|
|
81
|
+
pattern[:confidence] = (pattern[:confidence] + CONFIDENCE_FAILURE_DELTA).clamp(0.0, 1.0)
|
|
82
|
+
end
|
|
83
|
+
sync_to_persistence(intent_hash)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def promote_candidate(intent_hash:, tool_chain:, intent_text:, intent_vector: nil)
|
|
87
|
+
pattern = {
|
|
88
|
+
intent_hash: intent_hash,
|
|
89
|
+
intent_text: intent_text,
|
|
90
|
+
intent_vector: intent_vector,
|
|
91
|
+
tool_chain: tool_chain,
|
|
92
|
+
response_template: nil,
|
|
93
|
+
confidence: SEEDED_CONFIDENCE,
|
|
94
|
+
hit_count: 0,
|
|
95
|
+
miss_count: 0,
|
|
96
|
+
last_hit_at: nil,
|
|
97
|
+
created_at: Time.now,
|
|
98
|
+
context_requirements: nil
|
|
99
|
+
}
|
|
100
|
+
store(pattern)
|
|
101
|
+
candidates_mutex.synchronize { candidates_buffer.delete(intent_hash) }
|
|
102
|
+
pattern
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def record_candidate(intent_hash:, tool_chain:, intent_text:, threshold: 3)
|
|
106
|
+
candidates_mutex.synchronize do
|
|
107
|
+
entry = candidates_buffer[intent_hash] ||= { intent_text: intent_text, tool_chain: tool_chain,
|
|
108
|
+
count: 0 }
|
|
109
|
+
entry[:count] += 1
|
|
110
|
+
|
|
111
|
+
if entry[:count] >= threshold && !pattern_exists?(intent_hash)
|
|
112
|
+
candidates_buffer.delete(intent_hash)
|
|
113
|
+
return { promote: true, intent_hash: intent_hash, tool_chain: tool_chain,
|
|
114
|
+
intent_text: intent_text }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def candidates
|
|
121
|
+
candidates_mutex.synchronize { candidates_buffer.dup }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def patterns
|
|
125
|
+
mutex.synchronize { patterns_l0.dup }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def size
|
|
129
|
+
mutex.synchronize { patterns_l0.size }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def stats
|
|
133
|
+
total_hits = 0
|
|
134
|
+
total_conf = 0.0
|
|
135
|
+
count = 0
|
|
136
|
+
|
|
137
|
+
mutex.synchronize do
|
|
138
|
+
patterns_l0.each_value do |p|
|
|
139
|
+
total_hits += p[:hit_count] || 0
|
|
140
|
+
total_conf += p[:confidence] || 0.0
|
|
141
|
+
count += 1
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
size: count,
|
|
147
|
+
hit_rate: count.positive? ? (total_hits.to_f / [count, 1].max).round(2) : 0.0,
|
|
148
|
+
avg_confidence: count.positive? ? (total_conf / count).round(4) : 0.0
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def reset!
|
|
153
|
+
mutex.synchronize { patterns_l0.clear }
|
|
154
|
+
candidates_mutex.synchronize { candidates_buffer.clear }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# --- Private helpers ---
|
|
158
|
+
|
|
159
|
+
def pattern_exists?(intent_hash)
|
|
160
|
+
mutex.synchronize { patterns_l0.key?(intent_hash) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def cosine_similarity(vec_a, vec_b)
|
|
164
|
+
return 0.0 if vec_a.nil? || vec_b.nil? || vec_a.empty? || vec_b.empty?
|
|
165
|
+
|
|
166
|
+
dot = vec_a.zip(vec_b).sum { |a, b| a * b }
|
|
167
|
+
mag_a = Math.sqrt(vec_a.sum { |x| x**2 })
|
|
168
|
+
mag_b = Math.sqrt(vec_b.sum { |x| x**2 })
|
|
169
|
+
return 0.0 if mag_a.zero? || mag_b.zero?
|
|
170
|
+
|
|
171
|
+
dot / (mag_a * mag_b)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# --- L1: Cache (optional) ---
|
|
175
|
+
|
|
176
|
+
def persist_l1(intent_hash, pattern)
|
|
177
|
+
return unless defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected?
|
|
178
|
+
|
|
179
|
+
Legion::Cache.set("tbi:pattern:#{intent_hash}", Legion::JSON.dump(pattern), 3600)
|
|
180
|
+
rescue StandardError
|
|
181
|
+
nil
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def lookup_l1(intent_hash)
|
|
185
|
+
return nil unless defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected?
|
|
186
|
+
|
|
187
|
+
raw = Legion::Cache.get("tbi:pattern:#{intent_hash}")
|
|
188
|
+
raw ? Legion::JSON.load(raw) : nil
|
|
189
|
+
rescue StandardError
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# --- L2: Data::Local SQLite (optional) ---
|
|
194
|
+
|
|
195
|
+
def persist_l2(intent_hash, pattern)
|
|
196
|
+
return unless local_db_available?
|
|
197
|
+
|
|
198
|
+
table = ensure_local_table
|
|
199
|
+
data = serialize_pattern(pattern)
|
|
200
|
+
if table.where(intent_hash: intent_hash).first
|
|
201
|
+
table.where(intent_hash: intent_hash).update(data)
|
|
202
|
+
else
|
|
203
|
+
table.insert(data)
|
|
204
|
+
end
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def lookup_l2(intent_hash)
|
|
210
|
+
return nil unless local_db_available?
|
|
211
|
+
|
|
212
|
+
table = ensure_local_table
|
|
213
|
+
row = table.where(intent_hash: intent_hash).first
|
|
214
|
+
row ? deserialize_pattern(row) : nil
|
|
215
|
+
rescue StandardError
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def sync_to_persistence(intent_hash)
|
|
220
|
+
pattern = mutex.synchronize { patterns_l0[intent_hash]&.dup }
|
|
221
|
+
return unless pattern
|
|
222
|
+
|
|
223
|
+
persist_l1(intent_hash, pattern)
|
|
224
|
+
persist_l2(intent_hash, pattern)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def local_db_available?
|
|
228
|
+
defined?(Legion::Data::Local) &&
|
|
229
|
+
Legion::Data::Local.respond_to?(:connected?) &&
|
|
230
|
+
Legion::Data::Local.connected?
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def ensure_local_table
|
|
234
|
+
db = Legion::Data::Local.connection
|
|
235
|
+
unless db.table_exists?(:tbi_patterns)
|
|
236
|
+
db.create_table(:tbi_patterns) do
|
|
237
|
+
primary_key :id
|
|
238
|
+
String :intent_hash, null: false, unique: true
|
|
239
|
+
String :intent_text, text: true
|
|
240
|
+
String :intent_vector, text: true
|
|
241
|
+
String :tool_chain, text: true, null: false
|
|
242
|
+
String :response_template, text: true
|
|
243
|
+
Float :confidence, default: 0.5
|
|
244
|
+
Integer :hit_count, default: 0
|
|
245
|
+
Integer :miss_count, default: 0
|
|
246
|
+
DateTime :last_hit_at
|
|
247
|
+
DateTime :created_at
|
|
248
|
+
String :context_requirements, text: true
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
db[:tbi_patterns]
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def serialize_pattern(pattern)
|
|
255
|
+
{
|
|
256
|
+
intent_hash: pattern[:intent_hash],
|
|
257
|
+
intent_text: pattern[:intent_text],
|
|
258
|
+
intent_vector: pattern[:intent_vector] ? ::JSON.dump(pattern[:intent_vector]) : nil,
|
|
259
|
+
tool_chain: ::JSON.dump(pattern[:tool_chain]),
|
|
260
|
+
response_template: pattern[:response_template],
|
|
261
|
+
confidence: pattern[:confidence],
|
|
262
|
+
hit_count: pattern[:hit_count],
|
|
263
|
+
miss_count: pattern[:miss_count],
|
|
264
|
+
last_hit_at: pattern[:last_hit_at],
|
|
265
|
+
created_at: pattern[:created_at],
|
|
266
|
+
context_requirements: pattern[:context_requirements]&.then { |c| ::JSON.dump(c) }
|
|
267
|
+
}
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def deserialize_pattern(row)
|
|
271
|
+
{
|
|
272
|
+
intent_hash: row[:intent_hash],
|
|
273
|
+
intent_text: row[:intent_text],
|
|
274
|
+
intent_vector: row[:intent_vector] ? ::JSON.parse(row[:intent_vector]) : nil,
|
|
275
|
+
tool_chain: ::JSON.parse(row[:tool_chain]),
|
|
276
|
+
response_template: row[:response_template],
|
|
277
|
+
confidence: row[:confidence],
|
|
278
|
+
hit_count: row[:hit_count],
|
|
279
|
+
miss_count: row[:miss_count],
|
|
280
|
+
last_hit_at: row[:last_hit_at],
|
|
281
|
+
created_at: row[:created_at],
|
|
282
|
+
context_requirements: row[:context_requirements] ? ::JSON.parse(row[:context_requirements]) : nil
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def patterns_l0
|
|
287
|
+
@patterns_l0 ||= {}
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def mutex
|
|
291
|
+
@mutex ||= Mutex.new
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def candidates_buffer
|
|
295
|
+
@candidates_buffer ||= {}
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def candidates_mutex
|
|
299
|
+
@candidates_mutex ||= Mutex.new
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Resources
|
|
6
|
+
module ExtensionInfo
|
|
7
|
+
class << self
|
|
8
|
+
def static_resources
|
|
9
|
+
[]
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def resource_templates
|
|
13
|
+
[
|
|
14
|
+
::MCP::ResourceTemplate.new(
|
|
15
|
+
uri_template: 'legion://extensions/{name}',
|
|
16
|
+
name: 'extension-info',
|
|
17
|
+
description: 'Detailed info about a Legion extension including runners, actors, and functions.',
|
|
18
|
+
mime_type: 'application/json'
|
|
19
|
+
)
|
|
20
|
+
]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register_read_handler(_server)
|
|
24
|
+
# Read handler is registered by RunnerCatalog to handle both resource types
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def read(uri)
|
|
28
|
+
name = uri.sub('legion://extensions/', '')
|
|
29
|
+
return [] if name.empty?
|
|
30
|
+
|
|
31
|
+
unless data_connected?
|
|
32
|
+
return [{ uri: uri, mimeType: 'application/json',
|
|
33
|
+
text: Legion::JSON.dump({ error: 'legion-data is not connected' }) }]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
ext = Legion::Data::Model::Extension.where(name: name).first
|
|
37
|
+
unless ext
|
|
38
|
+
return [{ uri: uri, mimeType: 'application/json',
|
|
39
|
+
text: Legion::JSON.dump({ error: "Extension '#{name}' not found" }) }]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
runners = Legion::Data::Model::Runner.where(extension_id: ext.values[:id]).all
|
|
43
|
+
result = ext.values.merge(
|
|
44
|
+
runners: runners.map do |r|
|
|
45
|
+
functions = Legion::Data::Model::Function.where(runner_id: r.values[:id]).all
|
|
46
|
+
r.values.merge(functions: functions.map(&:values))
|
|
47
|
+
end
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
[{ uri: uri, mimeType: 'application/json', text: Legion::JSON.dump(result) }]
|
|
51
|
+
rescue StandardError => e
|
|
52
|
+
[{ uri: uri, mimeType: 'application/json',
|
|
53
|
+
text: Legion::JSON.dump({ error: "Failed to read extension: #{e.message}" }) }]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def data_connected?
|
|
59
|
+
Legion::Settings[:data][:connected]
|
|
60
|
+
rescue StandardError
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Resources
|
|
6
|
+
module RunnerCatalog
|
|
7
|
+
RESOURCE = ::MCP::Resource.new(
|
|
8
|
+
uri: 'legion://runners',
|
|
9
|
+
name: 'runner-catalog',
|
|
10
|
+
description: 'All available extension.runner.function paths in this Legion instance.',
|
|
11
|
+
mime_type: 'application/json'
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def register(server)
|
|
16
|
+
server.resources << RESOURCE
|
|
17
|
+
|
|
18
|
+
server.resources_read_handler do |params|
|
|
19
|
+
if params[:uri] == 'legion://runners'
|
|
20
|
+
[{ uri: 'legion://runners', mimeType: 'application/json', text: catalog_json }]
|
|
21
|
+
elsif params[:uri]&.start_with?('legion://extensions/')
|
|
22
|
+
ExtensionInfo.read(params[:uri])
|
|
23
|
+
else
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def catalog_json
|
|
32
|
+
return Legion::JSON.dump({ error: 'legion-data is not connected' }) unless data_connected?
|
|
33
|
+
|
|
34
|
+
extensions = Legion::Data::Model::Extension.all
|
|
35
|
+
catalog = extensions.map do |ext|
|
|
36
|
+
runners = Legion::Data::Model::Runner.where(extension_id: ext.values[:id]).all
|
|
37
|
+
{
|
|
38
|
+
extension: ext.values[:name],
|
|
39
|
+
runners: runners.map do |r|
|
|
40
|
+
functions = Legion::Data::Model::Function.where(runner_id: r.values[:id]).all
|
|
41
|
+
{
|
|
42
|
+
runner: r.values[:namespace],
|
|
43
|
+
functions: functions.map { |f| f.values[:name] }
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Legion::JSON.dump(catalog)
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
Legion::JSON.dump({ error: "Failed to build catalog: #{e.message}" })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def data_connected?
|
|
55
|
+
Legion::Settings[:data][:connected]
|
|
56
|
+
rescue StandardError
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'observer'
|
|
4
|
+
require_relative 'usage_filter'
|
|
5
|
+
require_relative 'tools/run_task'
|
|
6
|
+
require_relative 'tools/describe_runner'
|
|
7
|
+
require_relative 'tools/list_tasks'
|
|
8
|
+
require_relative 'tools/get_task'
|
|
9
|
+
require_relative 'tools/delete_task'
|
|
10
|
+
require_relative 'tools/get_task_logs'
|
|
11
|
+
require_relative 'tools/list_chains'
|
|
12
|
+
require_relative 'tools/create_chain'
|
|
13
|
+
require_relative 'tools/update_chain'
|
|
14
|
+
require_relative 'tools/delete_chain'
|
|
15
|
+
require_relative 'tools/list_relationships'
|
|
16
|
+
require_relative 'tools/create_relationship'
|
|
17
|
+
require_relative 'tools/update_relationship'
|
|
18
|
+
require_relative 'tools/delete_relationship'
|
|
19
|
+
require_relative 'tools/list_extensions'
|
|
20
|
+
require_relative 'tools/get_extension'
|
|
21
|
+
require_relative 'tools/enable_extension'
|
|
22
|
+
require_relative 'tools/disable_extension'
|
|
23
|
+
require_relative 'tools/list_schedules'
|
|
24
|
+
require_relative 'tools/create_schedule'
|
|
25
|
+
require_relative 'tools/update_schedule'
|
|
26
|
+
require_relative 'tools/delete_schedule'
|
|
27
|
+
require_relative 'tools/get_status'
|
|
28
|
+
require_relative 'tools/get_config'
|
|
29
|
+
require_relative 'tools/list_workers'
|
|
30
|
+
require_relative 'tools/show_worker'
|
|
31
|
+
require_relative 'tools/worker_lifecycle'
|
|
32
|
+
require_relative 'tools/worker_costs'
|
|
33
|
+
require_relative 'tools/team_summary'
|
|
34
|
+
require_relative 'tools/routing_stats'
|
|
35
|
+
require_relative 'tools/rbac_check'
|
|
36
|
+
require_relative 'tools/rbac_assignments'
|
|
37
|
+
require_relative 'tools/rbac_grants'
|
|
38
|
+
require_relative 'context_compiler'
|
|
39
|
+
require_relative 'embedding_index'
|
|
40
|
+
require_relative 'tools/do_action'
|
|
41
|
+
require_relative 'tools/discover_tools'
|
|
42
|
+
require_relative 'resources/runner_catalog'
|
|
43
|
+
require_relative 'resources/extension_info'
|
|
44
|
+
|
|
45
|
+
module Legion
|
|
46
|
+
module MCP
|
|
47
|
+
module Server
|
|
48
|
+
TOOL_CLASSES = [
|
|
49
|
+
Tools::RunTask,
|
|
50
|
+
Tools::DescribeRunner,
|
|
51
|
+
Tools::ListTasks,
|
|
52
|
+
Tools::GetTask,
|
|
53
|
+
Tools::DeleteTask,
|
|
54
|
+
Tools::GetTaskLogs,
|
|
55
|
+
Tools::ListChains,
|
|
56
|
+
Tools::CreateChain,
|
|
57
|
+
Tools::UpdateChain,
|
|
58
|
+
Tools::DeleteChain,
|
|
59
|
+
Tools::ListRelationships,
|
|
60
|
+
Tools::CreateRelationship,
|
|
61
|
+
Tools::UpdateRelationship,
|
|
62
|
+
Tools::DeleteRelationship,
|
|
63
|
+
Tools::ListExtensions,
|
|
64
|
+
Tools::GetExtension,
|
|
65
|
+
Tools::EnableExtension,
|
|
66
|
+
Tools::DisableExtension,
|
|
67
|
+
Tools::ListSchedules,
|
|
68
|
+
Tools::CreateSchedule,
|
|
69
|
+
Tools::UpdateSchedule,
|
|
70
|
+
Tools::DeleteSchedule,
|
|
71
|
+
Tools::GetStatus,
|
|
72
|
+
Tools::GetConfig,
|
|
73
|
+
Tools::ListWorkers,
|
|
74
|
+
Tools::ShowWorker,
|
|
75
|
+
Tools::WorkerLifecycle,
|
|
76
|
+
Tools::WorkerCosts,
|
|
77
|
+
Tools::TeamSummary,
|
|
78
|
+
Tools::RoutingStats,
|
|
79
|
+
Tools::RbacCheck,
|
|
80
|
+
Tools::RbacAssignments,
|
|
81
|
+
Tools::RbacGrants,
|
|
82
|
+
Tools::DoAction,
|
|
83
|
+
Tools::DiscoverTools
|
|
84
|
+
].freeze
|
|
85
|
+
|
|
86
|
+
class << self
|
|
87
|
+
def build(identity: nil)
|
|
88
|
+
tools = if ToolGovernance.governance_enabled?
|
|
89
|
+
ToolGovernance.filter_tools(TOOL_CLASSES, identity)
|
|
90
|
+
else
|
|
91
|
+
TOOL_CLASSES
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
server = ::MCP::Server.new(
|
|
95
|
+
name: 'legion',
|
|
96
|
+
version: defined?(Legion::VERSION) ? Legion::VERSION : Legion::MCP::VERSION,
|
|
97
|
+
instructions: instructions,
|
|
98
|
+
tools: tools,
|
|
99
|
+
resources: Resources::ExtensionInfo.static_resources,
|
|
100
|
+
resource_templates: Resources::ExtensionInfo.resource_templates
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if defined?(Observer)
|
|
104
|
+
::MCP.configure do |c|
|
|
105
|
+
c.instrumentation_callback = ->(idata) { Server.wire_observer(idata) }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
server.tools_list_handler do |_params|
|
|
110
|
+
build_filtered_tool_list.map(&:to_h)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Populate embedding index for semantic tool matching (lazy — no-op if LLM unavailable)
|
|
114
|
+
populate_embedding_index
|
|
115
|
+
|
|
116
|
+
Resources::RunnerCatalog.register(server)
|
|
117
|
+
Resources::ExtensionInfo.register_read_handler(server)
|
|
118
|
+
|
|
119
|
+
server
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def populate_embedding_index(embedder: EmbeddingIndex.default_embedder)
|
|
123
|
+
return unless embedder
|
|
124
|
+
|
|
125
|
+
tool_data = ContextCompiler.tool_index.values
|
|
126
|
+
EmbeddingIndex.build_from_tool_data(tool_data, embedder: embedder)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def wire_observer(data)
|
|
130
|
+
return unless data[:method] == 'tools/call' && data[:tool_name]
|
|
131
|
+
|
|
132
|
+
duration_ms = (data[:duration].to_f * 1000).to_i
|
|
133
|
+
params_keys = data[:tool_arguments].respond_to?(:keys) ? data[:tool_arguments].keys : []
|
|
134
|
+
success = data[:error].nil?
|
|
135
|
+
|
|
136
|
+
Observer.record(
|
|
137
|
+
tool_name: data[:tool_name],
|
|
138
|
+
duration_ms: duration_ms,
|
|
139
|
+
success: success,
|
|
140
|
+
params_keys: params_keys,
|
|
141
|
+
error: data[:error]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Wire pattern promotion feedback loop for do_action calls
|
|
145
|
+
return unless data[:tool_name] == 'legion.do' && data[:tool_arguments]&.dig(:intent)
|
|
146
|
+
|
|
147
|
+
Observer.record_intent_with_result(
|
|
148
|
+
intent: data[:tool_arguments][:intent],
|
|
149
|
+
tool_name: data[:tool_name],
|
|
150
|
+
success: success
|
|
151
|
+
)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def build_filtered_tool_list(keywords: [])
|
|
155
|
+
tool_names = TOOL_CLASSES.map { |tc| tc.respond_to?(:tool_name) ? tc.tool_name : tc.name }
|
|
156
|
+
ranked = UsageFilter.ranked_tools(tool_names, keywords: keywords)
|
|
157
|
+
ranked.filter_map do |name|
|
|
158
|
+
TOOL_CLASSES.find do |tc|
|
|
159
|
+
(tc.respond_to?(:tool_name) ? tc.tool_name : tc.name) == name
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def instructions
|
|
167
|
+
<<~TEXT
|
|
168
|
+
Legion is an async job engine. You can run tasks, create chains and relationships between services, manage extensions, and query system status.
|
|
169
|
+
|
|
170
|
+
Use `legion.run_task` with dot notation (e.g., "http.request.get") for quick task execution.
|
|
171
|
+
Use `legion.describe_runner` to discover available functions on a runner.
|
|
172
|
+
CRUD tools follow the pattern: legion.list_*, legion.create_*, legion.get_*, legion.update_*, legion.delete_*.
|
|
173
|
+
TEXT
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|