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,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