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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative 'pattern_store'
|
|
5
|
+
require_relative 'context_guard'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module MCP
|
|
9
|
+
module TierRouter
|
|
10
|
+
CONFIDENCE_TIER0 = 0.8
|
|
11
|
+
CONFIDENCE_TIER1 = 0.6
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def route(intent:, params: {}, context: {})
|
|
16
|
+
start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
17
|
+
normalized = normalize_intent(intent)
|
|
18
|
+
intent_hash = Digest::SHA256.hexdigest(normalized)
|
|
19
|
+
|
|
20
|
+
ContextGuard.record_request(intent_hash)
|
|
21
|
+
|
|
22
|
+
pattern = PatternStore.lookup(intent_hash)
|
|
23
|
+
pattern ||= try_semantic_lookup(normalized)
|
|
24
|
+
|
|
25
|
+
return tier2_response('no matching pattern') unless pattern
|
|
26
|
+
|
|
27
|
+
confidence = pattern[:confidence] || 0.0
|
|
28
|
+
|
|
29
|
+
return tier2_response('low confidence') if confidence < CONFIDENCE_TIER1
|
|
30
|
+
|
|
31
|
+
return tier1_response(pattern, 'confidence below tier 0 threshold') if confidence < CONFIDENCE_TIER0
|
|
32
|
+
|
|
33
|
+
guard_result = ContextGuard.check(pattern, params, context)
|
|
34
|
+
return tier1_response(pattern, guard_result[:reason]) unless guard_result[:passed]
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
results = execute_tool_chain(pattern[:tool_chain], params)
|
|
38
|
+
response = generate_response(results, pattern)
|
|
39
|
+
PatternStore.record_hit(intent_hash)
|
|
40
|
+
|
|
41
|
+
elapsed_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
|
42
|
+
{
|
|
43
|
+
tier: 0,
|
|
44
|
+
response: response,
|
|
45
|
+
latency_ms: elapsed_ms,
|
|
46
|
+
pattern_confidence: pattern[:confidence]
|
|
47
|
+
}
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
PatternStore.record_miss(intent_hash)
|
|
50
|
+
tier1_response(pattern, "tool chain failed: #{e.message}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def normalize_intent(intent)
|
|
55
|
+
intent.to_s.strip.downcase.gsub(/\s+/, ' ')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def execute_tool_chain(tool_chain, params)
|
|
59
|
+
tool_chain.map do |tool_name|
|
|
60
|
+
tool_class = find_tool_class(tool_name)
|
|
61
|
+
raise ArgumentError, "unknown tool: #{tool_name}" unless tool_class
|
|
62
|
+
|
|
63
|
+
if params.empty?
|
|
64
|
+
tool_class.call
|
|
65
|
+
else
|
|
66
|
+
tool_class.call(**params.transform_keys(&:to_sym))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def generate_response(results, pattern)
|
|
72
|
+
template = pattern[:response_template]
|
|
73
|
+
|
|
74
|
+
if template && transformer_available?
|
|
75
|
+
begin
|
|
76
|
+
client = Legion::Extensions::Transformer::Client.new
|
|
77
|
+
rendered = client.transform(transformation: template, payload: { results: results })
|
|
78
|
+
return rendered[:result] if rendered[:success]
|
|
79
|
+
rescue StandardError
|
|
80
|
+
# Fall through to raw results
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
results.size == 1 ? results.first : results
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def try_semantic_lookup(normalized_intent)
|
|
88
|
+
return nil unless defined?(Legion::MCP::EmbeddingIndex) && Legion::MCP::EmbeddingIndex.populated?
|
|
89
|
+
|
|
90
|
+
embedder = Legion::MCP::EmbeddingIndex.instance_variable_get(:@embedder)
|
|
91
|
+
return nil unless embedder
|
|
92
|
+
|
|
93
|
+
intent_vector = embedder.call(normalized_intent)
|
|
94
|
+
return nil unless intent_vector
|
|
95
|
+
|
|
96
|
+
PatternStore.lookup_semantic(intent_vector)
|
|
97
|
+
rescue StandardError
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def find_tool_class(tool_name)
|
|
102
|
+
return nil unless defined?(Legion::MCP::Server::TOOL_CLASSES)
|
|
103
|
+
|
|
104
|
+
Legion::MCP::Server::TOOL_CLASSES.find do |klass|
|
|
105
|
+
klass.respond_to?(:tool_name) && klass.tool_name == tool_name
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def transformer_available?
|
|
110
|
+
defined?(Legion::Extensions::Transformer::Client)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def tier1_response(pattern, reason)
|
|
114
|
+
{ tier: 1, response: nil, pattern: pattern, reason: reason }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def tier2_response(reason)
|
|
118
|
+
{ tier: 2, response: nil, reason: reason }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module ToolGovernance
|
|
6
|
+
RISK_TIER_ORDER = { low: 0, medium: 1, high: 2, critical: 3 }.freeze
|
|
7
|
+
|
|
8
|
+
DEFAULT_TOOL_TIERS = {
|
|
9
|
+
'legion.list_workers' => :low,
|
|
10
|
+
'legion.show_worker' => :low,
|
|
11
|
+
'legion.list_tasks' => :low,
|
|
12
|
+
'legion.get_task' => :low,
|
|
13
|
+
'legion.get_status' => :low,
|
|
14
|
+
'legion.get_config' => :low,
|
|
15
|
+
'legion.describe_runner' => :low,
|
|
16
|
+
'legion.list_extensions' => :low,
|
|
17
|
+
'legion.run_task' => :medium,
|
|
18
|
+
'legion.create_schedule' => :medium,
|
|
19
|
+
'legion.worker_lifecycle' => :high,
|
|
20
|
+
'legion.enable_extension' => :high,
|
|
21
|
+
'legion.disable_extension' => :high,
|
|
22
|
+
'legion.delete_task' => :high,
|
|
23
|
+
'legion.rbac_assignments' => :high,
|
|
24
|
+
'legion.rbac_grants' => :high
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def filter_tools(tools, identity)
|
|
30
|
+
return tools unless governance_enabled?
|
|
31
|
+
|
|
32
|
+
risk_tier = identity&.dig(:risk_tier) || :low
|
|
33
|
+
tier_value = RISK_TIER_ORDER[risk_tier] || 0
|
|
34
|
+
|
|
35
|
+
tool_tiers = DEFAULT_TOOL_TIERS.merge(custom_tiers)
|
|
36
|
+
tools.select do |tool|
|
|
37
|
+
tool_tier = tool_tiers[tool_name(tool)] || :low
|
|
38
|
+
(RISK_TIER_ORDER[tool_tier] || 0) <= tier_value
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def audit_invocation(tool_name:, identity:, params:, result:)
|
|
43
|
+
return unless audit_enabled? && defined?(Legion::Audit)
|
|
44
|
+
|
|
45
|
+
Legion::Audit.record(
|
|
46
|
+
event_type: 'mcp_tool_invocation',
|
|
47
|
+
principal_id: identity&.dig(:worker_id) || identity&.dig(:user_id) || 'unknown',
|
|
48
|
+
action: "mcp.#{tool_name}",
|
|
49
|
+
resource: 'mcp_tool',
|
|
50
|
+
detail: { param_keys: params&.keys, success: !result&.dig(:error) }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def governance_enabled?
|
|
55
|
+
Legion::Settings.dig(:mcp, :governance, :enabled) == true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def audit_enabled?
|
|
59
|
+
Legion::Settings.dig(:mcp, :governance, :audit_invocations) != false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def custom_tiers
|
|
63
|
+
Legion::Settings.dig(:mcp, :governance, :tool_risk_tiers) || {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def tool_name(tool)
|
|
67
|
+
if tool.respond_to?(:tool_name)
|
|
68
|
+
tool.tool_name
|
|
69
|
+
elsif tool.respond_to?(:name)
|
|
70
|
+
tool.name
|
|
71
|
+
else
|
|
72
|
+
tool.to_s
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class CreateChain < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.create_chain'
|
|
8
|
+
description 'Create a new task chain.'
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
name: { type: 'string', description: 'Chain name' }
|
|
13
|
+
},
|
|
14
|
+
required: ['name']
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(name:, **attrs)
|
|
19
|
+
return error_response('legion-data is not connected') unless data_connected?
|
|
20
|
+
return error_response('chain data model is not available') unless chain_model?
|
|
21
|
+
|
|
22
|
+
id = Legion::Data::Model::Chain.insert(attrs.merge(name: name))
|
|
23
|
+
record = Legion::Data::Model::Chain[id]
|
|
24
|
+
text_response(record.values)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
error_response("Failed to create chain: #{e.message}")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def data_connected?
|
|
32
|
+
Legion::Settings[:data][:connected]
|
|
33
|
+
rescue StandardError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def chain_model? = Legion::Data::Model.const_defined?(:Chain)
|
|
38
|
+
|
|
39
|
+
def text_response(data)
|
|
40
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def error_response(msg)
|
|
44
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class CreateRelationship < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.create_relationship'
|
|
8
|
+
description 'Create a new relationship between tasks/functions.'
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
trigger_function_id: { type: 'integer', description: 'Function ID that triggers this relationship' },
|
|
13
|
+
target_function_id: { type: 'integer', description: 'Function ID to be triggered' }
|
|
14
|
+
},
|
|
15
|
+
required: %w[trigger_function_id target_function_id]
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def call(**attrs)
|
|
20
|
+
return error_response('legion-data is not connected') unless data_connected?
|
|
21
|
+
return error_response('relationship data model is not available') unless relationship_model?
|
|
22
|
+
|
|
23
|
+
id = Legion::Data::Model::Relationship.insert(attrs)
|
|
24
|
+
record = Legion::Data::Model::Relationship[id]
|
|
25
|
+
text_response(record.values)
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
error_response("Failed to create relationship: #{e.message}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def data_connected?
|
|
33
|
+
Legion::Settings[:data][:connected]
|
|
34
|
+
rescue StandardError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def relationship_model? = Legion::Data::Model.const_defined?(:Relationship)
|
|
39
|
+
|
|
40
|
+
def text_response(data)
|
|
41
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def error_response(msg)
|
|
45
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class CreateSchedule < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.create_schedule'
|
|
8
|
+
description 'Create a new schedule. Requires function_id and either cron or interval.'
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
function_id: { type: 'integer', description: 'Function ID to schedule' },
|
|
13
|
+
cron: { type: 'string', description: 'Cron expression (e.g., "*/5 * * * *")' },
|
|
14
|
+
interval: { type: 'integer', description: 'Interval in seconds' },
|
|
15
|
+
active: { type: 'boolean', description: 'Whether schedule is active (default true)' },
|
|
16
|
+
payload: { type: 'object', description: 'Payload to pass to the function', additionalProperties: true }
|
|
17
|
+
},
|
|
18
|
+
required: ['function_id']
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def call(function_id:, cron: nil, interval: nil, active: true, payload: {})
|
|
23
|
+
return error_response('legion-data is not connected') unless data_connected?
|
|
24
|
+
return error_response('lex-scheduler is not loaded') unless scheduler_loaded?
|
|
25
|
+
return error_response('cron or interval is required') if cron.nil? && interval.nil?
|
|
26
|
+
|
|
27
|
+
attrs = {
|
|
28
|
+
function_id: function_id.to_i,
|
|
29
|
+
active: active,
|
|
30
|
+
payload: Legion::JSON.dump(payload),
|
|
31
|
+
last_run: Time.at(0)
|
|
32
|
+
}
|
|
33
|
+
attrs[:cron] = cron if cron
|
|
34
|
+
attrs[:interval] = interval.to_i if interval
|
|
35
|
+
|
|
36
|
+
id = Legion::Extensions::Scheduler::Data::Model::Schedule.insert(attrs)
|
|
37
|
+
record = Legion::Extensions::Scheduler::Data::Model::Schedule[id]
|
|
38
|
+
text_response(record.values)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
error_response("Failed to create schedule: #{e.message}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def data_connected?
|
|
46
|
+
Legion::Settings[:data][:connected]
|
|
47
|
+
rescue StandardError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def scheduler_loaded? = defined?(Legion::Extensions::Scheduler)
|
|
52
|
+
|
|
53
|
+
def text_response(data)
|
|
54
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def error_response(msg)
|
|
58
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class DeleteChain < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.delete_chain'
|
|
8
|
+
description 'Delete a task chain by ID.'
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
id: { type: 'integer', description: 'Chain ID' }
|
|
13
|
+
},
|
|
14
|
+
required: ['id']
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(id:)
|
|
19
|
+
return error_response('legion-data is not connected') unless data_connected?
|
|
20
|
+
return error_response('chain data model is not available') unless chain_model?
|
|
21
|
+
|
|
22
|
+
record = Legion::Data::Model::Chain[id.to_i]
|
|
23
|
+
return error_response("Chain #{id} not found") unless record
|
|
24
|
+
|
|
25
|
+
record.delete
|
|
26
|
+
text_response({ deleted: true, id: id })
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
error_response("Failed to delete chain: #{e.message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def data_connected?
|
|
34
|
+
Legion::Settings[:data][:connected]
|
|
35
|
+
rescue StandardError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def chain_model? = Legion::Data::Model.const_defined?(:Chain)
|
|
40
|
+
|
|
41
|
+
def text_response(data)
|
|
42
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def error_response(msg)
|
|
46
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class DeleteRelationship < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.delete_relationship'
|
|
8
|
+
description 'Delete a relationship by ID.'
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
id: { type: 'integer', description: 'Relationship ID' }
|
|
13
|
+
},
|
|
14
|
+
required: ['id']
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(id:)
|
|
19
|
+
return error_response('legion-data is not connected') unless data_connected?
|
|
20
|
+
return error_response('relationship data model is not available') unless relationship_model?
|
|
21
|
+
|
|
22
|
+
record = Legion::Data::Model::Relationship[id.to_i]
|
|
23
|
+
return error_response("Relationship #{id} not found") unless record
|
|
24
|
+
|
|
25
|
+
record.delete
|
|
26
|
+
text_response({ deleted: true, id: id })
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
error_response("Failed to delete relationship: #{e.message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def data_connected?
|
|
34
|
+
Legion::Settings[:data][:connected]
|
|
35
|
+
rescue StandardError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def relationship_model? = Legion::Data::Model.const_defined?(:Relationship)
|
|
40
|
+
|
|
41
|
+
def text_response(data)
|
|
42
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def error_response(msg)
|
|
46
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class DeleteSchedule < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.delete_schedule'
|
|
8
|
+
description 'Delete a schedule by ID.'
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
id: { type: 'integer', description: 'Schedule ID' }
|
|
13
|
+
},
|
|
14
|
+
required: ['id']
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(id:)
|
|
19
|
+
return error_response('legion-data is not connected') unless data_connected?
|
|
20
|
+
return error_response('lex-scheduler is not loaded') unless scheduler_loaded?
|
|
21
|
+
|
|
22
|
+
record = Legion::Extensions::Scheduler::Data::Model::Schedule[id.to_i]
|
|
23
|
+
return error_response("Schedule #{id} not found") unless record
|
|
24
|
+
|
|
25
|
+
record.delete
|
|
26
|
+
text_response({ deleted: true, id: id })
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
error_response("Failed to delete schedule: #{e.message}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def data_connected?
|
|
34
|
+
Legion::Settings[:data][:connected]
|
|
35
|
+
rescue StandardError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def scheduler_loaded? = defined?(Legion::Extensions::Scheduler)
|
|
40
|
+
|
|
41
|
+
def text_response(data)
|
|
42
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def error_response(msg)
|
|
46
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class DeleteTask < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.delete_task'
|
|
8
|
+
description 'Delete a task by ID.'
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
id: { type: 'integer', description: 'Task ID' }
|
|
13
|
+
},
|
|
14
|
+
required: ['id']
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(id:)
|
|
19
|
+
return error_response('legion-data is not connected') unless data_connected?
|
|
20
|
+
|
|
21
|
+
task = Legion::Data::Model::Task[id.to_i]
|
|
22
|
+
return error_response("Task #{id} not found") unless task
|
|
23
|
+
|
|
24
|
+
task.delete
|
|
25
|
+
text_response({ deleted: true, id: id })
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
error_response("Failed to delete task: #{e.message}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def data_connected?
|
|
33
|
+
Legion::Settings[:data][:connected]
|
|
34
|
+
rescue StandardError
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def text_response(data)
|
|
39
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def error_response(msg)
|
|
43
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module MCP
|
|
5
|
+
module Tools
|
|
6
|
+
class DescribeRunner < ::MCP::Tool
|
|
7
|
+
tool_name 'legion.describe_runner'
|
|
8
|
+
description 'Discover available functions on a runner. Use dot notation (e.g., "http.request") or omit to list all.'
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
runner: {
|
|
13
|
+
type: 'string',
|
|
14
|
+
description: 'Dot notation path: extension.runner (e.g., "http.request"). Omit to list all.'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def call(runner: nil)
|
|
21
|
+
return error_response('legion-data is not connected') unless data_connected?
|
|
22
|
+
|
|
23
|
+
runner ? describe_single(runner) : describe_all
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
error_response("Failed to describe runners: #{e.message}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def data_connected?
|
|
31
|
+
Legion::Settings[:data][:connected]
|
|
32
|
+
rescue StandardError
|
|
33
|
+
false
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def describe_single(runner)
|
|
37
|
+
parts = runner.split('.')
|
|
38
|
+
return error_response("Invalid format '#{runner}'. Expected: extension.runner") unless parts.length == 2
|
|
39
|
+
|
|
40
|
+
runners = Legion::Data::Model::Runner.all
|
|
41
|
+
matching = runners.select do |r|
|
|
42
|
+
ns = r.values[:namespace]&.downcase
|
|
43
|
+
ns&.include?(parts[0]) && ns.include?(parts[1])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
return error_response("No runner found matching '#{runner}'") if matching.empty?
|
|
47
|
+
|
|
48
|
+
results = matching.map do |r|
|
|
49
|
+
functions = Legion::Data::Model::Function.where(runner_id: r.values[:id]).all
|
|
50
|
+
{
|
|
51
|
+
runner: r.values[:namespace],
|
|
52
|
+
runner_id: r.values[:id],
|
|
53
|
+
functions: functions.map { |f| { id: f.values[:id], name: f.values[:name] } }
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
text_response(results)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def describe_all
|
|
61
|
+
extensions = Legion::Data::Model::Extension.all
|
|
62
|
+
catalog = extensions.map do |ext|
|
|
63
|
+
runners = Legion::Data::Model::Runner.where(extension_id: ext.values[:id]).all
|
|
64
|
+
{
|
|
65
|
+
extension: ext.values[:name],
|
|
66
|
+
extension_id: ext.values[:id],
|
|
67
|
+
runners: runners.map do |r|
|
|
68
|
+
functions = Legion::Data::Model::Function.where(runner_id: r.values[:id]).all
|
|
69
|
+
{
|
|
70
|
+
runner: r.values[:namespace],
|
|
71
|
+
runner_id: r.values[:id],
|
|
72
|
+
functions: functions.map { |f| { id: f.values[:id], name: f.values[:name] } }
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
text_response(catalog)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def text_response(data)
|
|
82
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def error_response(message)
|
|
86
|
+
::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: message }) }], error: true)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|