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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class ListSchedules < ::MCP::Tool
7
+ tool_name 'legion.list_schedules'
8
+ description 'List all schedules. Requires lex-scheduler.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ active: { type: 'boolean', description: 'Filter by active status' },
13
+ limit: { type: 'integer', description: 'Max results (default 25, max 100)' }
14
+ }
15
+ )
16
+
17
+ class << self
18
+ def call(active: nil, limit: 25)
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
+ limit = limit.to_i.clamp(1, 100)
23
+ dataset = Legion::Extensions::Scheduler::Data::Model::Schedule.order(:id)
24
+ dataset = dataset.where(active: true) if active == true
25
+ text_response(dataset.limit(limit).all.map(&:values))
26
+ rescue StandardError => e
27
+ error_response("Failed to list schedules: #{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 scheduler_loaded? = defined?(Legion::Extensions::Scheduler)
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,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class ListTasks < ::MCP::Tool
7
+ tool_name 'legion.list_tasks'
8
+ description 'List recent tasks with optional filtering by status or function_id.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ status: { type: 'string', description: 'Filter by task status' },
13
+ function_id: { type: 'integer', description: 'Filter by function ID' },
14
+ limit: { type: 'integer', description: 'Max results (default 25, max 100)' }
15
+ }
16
+ )
17
+
18
+ class << self
19
+ def call(status: nil, function_id: nil, limit: 25)
20
+ return error_response('legion-data is not connected') unless data_connected?
21
+
22
+ limit = limit.to_i.clamp(1, 100)
23
+ dataset = Legion::Data::Model::Task.order(Sequel.desc(:id))
24
+ dataset = dataset.where(status: status) if status
25
+ dataset = dataset.where(function_id: function_id.to_i) if function_id
26
+ text_response(dataset.limit(limit).all.map(&:values))
27
+ rescue StandardError => e
28
+ error_response("Failed to list tasks: #{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 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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class ListWorkers < ::MCP::Tool
7
+ tool_name 'legion.list_workers'
8
+ description 'List digital workers with optional filtering by team, owner, or lifecycle state.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ team: { type: 'string', description: 'Filter by team name' },
13
+ owner_msid: { type: 'string', description: 'Filter by owner MSID' },
14
+ lifecycle_state: { type: 'string',
15
+ description: 'Filter by lifecycle state (bootstrap, active, paused, retired, terminated)' },
16
+ limit: { type: 'integer', description: 'Max results (default 20, max 100)' }
17
+ }
18
+ )
19
+
20
+ class << self
21
+ def call(team: nil, owner_msid: nil, lifecycle_state: nil, limit: 20)
22
+ return error_response('legion-data is not connected') unless data_connected?
23
+
24
+ limit = limit.to_i.clamp(1, 100)
25
+ dataset = Legion::Data::Model::DigitalWorker.order(Sequel.desc(:id))
26
+ dataset = dataset.where(team: team) if team
27
+ dataset = dataset.where(owner_msid: owner_msid) if owner_msid
28
+ dataset = dataset.where(lifecycle_state: lifecycle_state) if lifecycle_state
29
+
30
+ text_response(dataset.limit(limit).all.map(&:values))
31
+ rescue StandardError => e
32
+ error_response("Failed to list workers: #{e.message}")
33
+ end
34
+
35
+ private
36
+
37
+ def data_connected?
38
+ Legion::Settings[:data][:connected]
39
+ rescue StandardError
40
+ false
41
+ end
42
+
43
+ def text_response(data)
44
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
45
+ end
46
+
47
+ def error_response(msg)
48
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class RbacAssignments < ::MCP::Tool
7
+ tool_name 'legion.rbac_assignments'
8
+ description 'List RBAC role assignments. Filterable by team, role, or principal.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ team: { type: 'string', description: 'Filter by team' },
13
+ role: { type: 'string', description: 'Filter by role name' },
14
+ principal: { type: 'string', description: 'Filter by principal ID' }
15
+ }
16
+ )
17
+
18
+ class << self
19
+ def call(team: nil, role: nil, principal: nil)
20
+ return error_response('legion-rbac not installed') unless defined?(Legion::Rbac)
21
+ return error_response('legion-data not connected') unless Legion::Rbac::Store.db_available?
22
+
23
+ ds = Legion::Data::Model::RbacRoleAssignment.dataset
24
+ ds = ds.where(team: team) if team
25
+ ds = ds.where(role: role) if role
26
+ ds = ds.where(principal_id: principal) if principal
27
+ text_response(ds.all.map(&:values))
28
+ rescue StandardError => e
29
+ error_response("Failed to list assignments: #{e.message}")
30
+ end
31
+
32
+ private
33
+
34
+ def text_response(data)
35
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
36
+ end
37
+
38
+ def error_response(msg)
39
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class RbacCheck < ::MCP::Tool
7
+ tool_name 'legion.rbac_check'
8
+ description 'Dry-run authorization check. Evaluates RBAC policies without enforcing.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ principal: { type: 'string', description: 'Principal ID to check' },
13
+ action: { type: 'string', description: 'Action (read, execute, manage, etc.)' },
14
+ resource: { type: 'string', description: 'Resource path (e.g. runners/lex-github/*)' },
15
+ roles: { type: 'array', items: { type: 'string' }, description: 'Roles to evaluate' },
16
+ team: { type: 'string', description: 'Team scope' }
17
+ },
18
+ required: %w[principal action resource roles]
19
+ )
20
+
21
+ class << self
22
+ def call(principal:, action:, resource:, roles: [], team: nil)
23
+ return error_response('legion-rbac not installed') unless defined?(Legion::Rbac)
24
+
25
+ p = Legion::Rbac::Principal.new(id: principal, roles: roles, team: team)
26
+ result = Legion::Rbac::PolicyEngine.evaluate(principal: p, action: action, resource: resource,
27
+ enforce: false)
28
+ text_response(result)
29
+ rescue StandardError => e
30
+ error_response("RBAC check failed: #{e.message}")
31
+ end
32
+
33
+ private
34
+
35
+ def text_response(data)
36
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
37
+ end
38
+
39
+ def error_response(msg)
40
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class RbacGrants < ::MCP::Tool
7
+ tool_name 'legion.rbac_grants'
8
+ description 'List RBAC runner grants. Filterable by team.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ team: { type: 'string', description: 'Filter by team' }
13
+ }
14
+ )
15
+
16
+ class << self
17
+ def call(team: nil)
18
+ return error_response('legion-rbac not installed') unless defined?(Legion::Rbac)
19
+ return error_response('legion-data not connected') unless Legion::Rbac::Store.db_available?
20
+
21
+ ds = Legion::Data::Model::RbacRunnerGrant.dataset
22
+ ds = ds.where(team: team) if team
23
+ text_response(ds.all.map(&:values))
24
+ rescue StandardError => e
25
+ error_response("Failed to list grants: #{e.message}")
26
+ end
27
+
28
+ private
29
+
30
+ def text_response(data)
31
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
32
+ end
33
+
34
+ def error_response(msg)
35
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class RoutingStats < ::MCP::Tool
7
+ tool_name 'legion.routing_stats'
8
+ description 'Retrieve LLM routing statistics: breakdown by provider, model, and routing reason. Requires lex-metering.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ worker_id: { type: 'string', description: 'Optional: filter stats to a specific worker UUID' }
13
+ }
14
+ )
15
+
16
+ class << self
17
+ def call(worker_id: nil)
18
+ return error_response('legion-data is not connected') unless data_connected?
19
+ return error_response('lex-metering is not loaded') unless metering_available?
20
+
21
+ runner = Object.new.extend(Legion::Extensions::Metering::Runners::Metering)
22
+ stats = runner.routing_stats(worker_id: worker_id)
23
+ text_response(stats)
24
+ rescue StandardError => e
25
+ error_response("Failed to fetch routing stats: #{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 metering_available?
37
+ defined?(Legion::Extensions::Metering::Runners::Metering)
38
+ end
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,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class RunTask < ::MCP::Tool
7
+ tool_name 'legion.run_task'
8
+ description 'Execute a Legion task using dot notation (e.g., "http.request.get"). Returns the task result.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ task: {
13
+ type: 'string',
14
+ description: 'Dot notation path: extension.runner.function (e.g., "http.request.get")'
15
+ },
16
+ params: {
17
+ type: 'object',
18
+ description: 'Parameters to pass to the task function',
19
+ additionalProperties: true
20
+ }
21
+ },
22
+ required: ['task']
23
+ )
24
+
25
+ class << self
26
+ def call(task:, params: {})
27
+ parts = task.split('.')
28
+ return error_response("Invalid dot notation '#{task}'. Expected format: extension.runner.function") unless parts.length == 3
29
+
30
+ ext_name, runner_name, function_name = parts
31
+ runner_class = resolve_runner_class(ext_name, runner_name)
32
+
33
+ result = Legion::Ingress.run(
34
+ payload: params,
35
+ runner_class: runner_class,
36
+ function: function_name.to_sym,
37
+ source: 'mcp',
38
+ check_subtask: true,
39
+ generate_task: true
40
+ )
41
+
42
+ text_response(result)
43
+ rescue NameError => e
44
+ error_response("Runner not found: #{e.message}")
45
+ rescue StandardError => e
46
+ error_response("Task execution failed: #{e.message}")
47
+ end
48
+
49
+ private
50
+
51
+ def resolve_runner_class(ext_name, runner_name)
52
+ ext_part = ext_name.split('_').map(&:capitalize).join
53
+ runner_part = runner_name.split('_').map(&:capitalize).join
54
+ "Legion::Extensions::#{ext_part}::Runners::#{runner_part}"
55
+ end
56
+
57
+ def text_response(data)
58
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
59
+ end
60
+
61
+ def error_response(message)
62
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: message }) }], error: true)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class ShowWorker < ::MCP::Tool
7
+ tool_name 'legion.show_worker'
8
+ description 'Get full details for a single digital worker by ID.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ worker_id: { type: 'string', description: 'UUID of the digital worker' }
13
+ },
14
+ required: ['worker_id']
15
+ )
16
+
17
+ class << self
18
+ def call(worker_id:)
19
+ return error_response('legion-data is not connected') unless data_connected?
20
+
21
+ worker = Legion::DigitalWorker.find(worker_id: worker_id)
22
+ return error_response("Worker not found: #{worker_id}") unless worker
23
+
24
+ text_response(worker.values)
25
+ rescue StandardError => e
26
+ error_response("Failed to fetch worker: #{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 text_response(data)
38
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
39
+ end
40
+
41
+ def error_response(msg)
42
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class TeamSummary < ::MCP::Tool
7
+ tool_name 'legion.team_summary'
8
+ description 'Get a summary of all digital workers for a team, including lifecycle state breakdown.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ team: { type: 'string', description: 'Team name to summarize' }
13
+ },
14
+ required: ['team']
15
+ )
16
+
17
+ class << self
18
+ def call(team:)
19
+ return error_response('legion-data is not connected') unless data_connected?
20
+
21
+ workers = Legion::DigitalWorker.by_team(team: team).all
22
+ breakdown = workers.each_with_object(Hash.new(0)) { |w, counts| counts[w.values[:lifecycle_state]] += 1 }
23
+
24
+ text_response({
25
+ team: team,
26
+ total: workers.size,
27
+ lifecycle_states: breakdown,
28
+ workers: workers.map do |w|
29
+ w.values.slice(:worker_id, :name, :lifecycle_state, :owner_msid, :business_role)
30
+ end
31
+ })
32
+ rescue StandardError => e
33
+ error_response("Failed to fetch team summary: #{e.message}")
34
+ end
35
+
36
+ private
37
+
38
+ def data_connected?
39
+ Legion::Settings[:data][:connected]
40
+ rescue StandardError
41
+ false
42
+ end
43
+
44
+ def text_response(data)
45
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
46
+ end
47
+
48
+ def error_response(msg)
49
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class UpdateChain < ::MCP::Tool
7
+ tool_name 'legion.update_chain'
8
+ description 'Update an existing task chain.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ id: { type: 'integer', description: 'Chain ID' },
13
+ name: { type: 'string', description: 'New chain name' }
14
+ },
15
+ required: ['id']
16
+ )
17
+
18
+ class << self
19
+ def call(id:, **attrs)
20
+ return error_response('legion-data is not connected') unless data_connected?
21
+ return error_response('chain data model is not available') unless chain_model?
22
+
23
+ record = Legion::Data::Model::Chain[id.to_i]
24
+ return error_response("Chain #{id} not found") unless record
25
+
26
+ record.update(attrs) unless attrs.empty?
27
+ record.refresh
28
+ text_response(record.values)
29
+ rescue StandardError => e
30
+ error_response("Failed to update chain: #{e.message}")
31
+ end
32
+
33
+ private
34
+
35
+ def data_connected?
36
+ Legion::Settings[:data][:connected]
37
+ rescue StandardError
38
+ false
39
+ end
40
+
41
+ def chain_model? = Legion::Data::Model.const_defined?(:Chain)
42
+
43
+ def text_response(data)
44
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
45
+ end
46
+
47
+ def error_response(msg)
48
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class UpdateRelationship < ::MCP::Tool
7
+ tool_name 'legion.update_relationship'
8
+ description 'Update an existing relationship.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ id: { type: 'integer', description: 'Relationship ID' },
13
+ trigger_function_id: { type: 'integer', description: 'New trigger function ID' },
14
+ target_function_id: { type: 'integer', description: 'New target function ID' }
15
+ },
16
+ required: ['id']
17
+ )
18
+
19
+ class << self
20
+ def call(id:, **attrs)
21
+ return error_response('legion-data is not connected') unless data_connected?
22
+ return error_response('relationship data model is not available') unless relationship_model?
23
+
24
+ record = Legion::Data::Model::Relationship[id.to_i]
25
+ return error_response("Relationship #{id} not found") unless record
26
+
27
+ record.update(attrs) unless attrs.empty?
28
+ record.refresh
29
+ text_response(record.values)
30
+ rescue StandardError => e
31
+ error_response("Failed to update relationship: #{e.message}")
32
+ end
33
+
34
+ private
35
+
36
+ def data_connected?
37
+ Legion::Settings[:data][:connected]
38
+ rescue StandardError
39
+ false
40
+ end
41
+
42
+ def relationship_model? = Legion::Data::Model.const_defined?(:Relationship)
43
+
44
+ def text_response(data)
45
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
46
+ end
47
+
48
+ def error_response(msg)
49
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end