Class: Legate::Mcp::Server::LegateToolAdapter

Inherits:
FastMcp::Tool
  • Object
show all
Defined in:
lib/legate/mcp/server/legate_tool_adapter.rb

Overview

Base adapter class to expose an Legate::Tool as an MCP tool via fast-mcp. Use the ‘wrap` class method to dynamically create subclasses for specific Legate tools.

Class Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Class Attribute Details

.legate_tool_classObject (readonly)

Provide a reader



18
19
20
# File 'lib/legate/mcp/server/legate_tool_adapter.rb', line 18

def legate_tool_class
  @legate_tool_class
end

Class Method Details

.wrap(legate_tool_class) ⇒ Class<LegateToolAdapter>

Dynamically creates a new FastMcp::Tool subclass that wraps the given Legate::Tool class.

Parameters:

  • legate_tool_class (Class<Legate::Tool>)

    The Legate::Tool class to wrap.

Returns:

  • (Class<LegateToolAdapter>)

    A new anonymous class inheriting from LegateToolAdapter.

Raises:

  • (ArgumentError)

    if the provided class is not an Legate::Tool.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/legate/mcp/server/legate_tool_adapter.rb', line 26

def self.wrap(legate_tool_class)
  raise ArgumentError, "Provided class #{legate_tool_class} is not a valid Legate::Tool class." unless legate_tool_class.is_a?(Class) && legate_tool_class < Legate::Tool

   = legate_tool_class.
  # Check metadata hash and required keys
  raise ArgumentError, "Legate::Tool #{legate_tool_class} has incomplete metadata (missing name or description)." unless .is_a?(Hash) && [:name] && [:description]

  mcp_tool_name = [:name].to_s
  mcp_description = [:description]
  legate_params = [:parameters] || {}

  # Convert Legate params to a Dry::Schema proc
  schema_proc = Legate::Mcp::Util::SchemaConverter.legate_to_dry_schema(legate_params)

  # Create the anonymous adapter class
  Class.new(LegateToolAdapter) do
    @legate_tool_class = legate_tool_class

    # Use fast-mcp DSL methods inside the class definition block
    tool_name mcp_tool_name # Use DSL method
    description mcp_description
    arguments(&schema_proc) if schema_proc

    Legate.logger.info("Created fast-mcp adapter for Legate tool: #{legate_tool_class} as '#{mcp_tool_name}'")
  end
end

Instance Method Details

#call(**args) ⇒ Any

The ‘call` method executed by fast-mcp when the tool is invoked. Instantiates the wrapped Legate tool, executes it with a dummy context, and translates the result/error.

Parameters:

  • args (Hash)

    Keyword arguments matching the defined schema.

Returns:

  • (Any)

    The successful result payload for the MCP response.

Raises:

  • (StandardError)

    If the Legate tool returns an error status.

  • (NotImplementedError)

    If the Legate tool returns a pending status (needs CheckJobStatusTool).



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/legate/mcp/server/legate_tool_adapter.rb', line 61

def call(**args)
  # Access the class instance variable via the reader
  tool_class = self.class.legate_tool_class
  raise NotImplementedError, 'LegateToolAdapter cannot be used directly, use .wrap first.' unless tool_class

  legate_instance = tool_class.new

  # Convert string keys from MCP/fast-mcp back to symbols for Legate tool
  legate_params = args.transform_keys(&:to_sym)

  # Create a dummy/minimal context for the Legate tool execution
  # TODO: Can we provide more meaningful context if running within a larger MCP session?
  dummy_context = Legate::ToolContext.new(
    session_id: SecureRandom.uuid, # Generic ID
    user_id: 'mcp_user',
    app_name: 'mcp_server',
    tool_registry: Legate::ToolRegistry.new # Create a new, empty registry for this dummy context
    # No session_service available here easily
  )

  Legate.logger.info("Executing Legate tool '#{self.class.tool_name}' via MCP adapter with params: #{legate_params.inspect}")

  begin
    result_hash = legate_instance.execute(legate_params, dummy_context)
  rescue StandardError => e
    # Catch errors during the tool's execute method itself
    Mcp.logger.error("Error during underlying Legate tool execution for '#{self.class.tool_name}': #{e.class} - #{e.message}")
    # Let fast-mcp handle this standard error, it should map to an MCP error response
    raise StandardError, "Execution Error in Legate tool '#{self.class.tool_name}': #{e.message}"
  end

  Legate.logger.debug("Legate tool '#{self.class.tool_name}' returned hash: #{result_hash.inspect}")

  # Translate Legate result hash to MCP return/error
  case result_hash[:status]
  when :success
    result_hash[:result] # Return the raw result for MCP
  when :error
    error_message = result_hash[:error_message] || "Unknown error from Legate tool '#{self.class.tool_name}'"
    Legate.logger.error("Legate tool '#{self.class.tool_name}' reported error: #{error_message}")
    # Raise a standard error, fast-mcp should convert this to an MCP error response
    raise StandardError, error_message
  when :pending
    job_id = result_hash[:job_id] # Assuming the key is :job_id now
    message = result_hash[:message] || "Legate tool '#{self.class.tool_name}' started an async job."
    Legate.logger.info("Legate tool '#{self.class.tool_name}' returned pending status (Job ID: #{job_id})")
    # Return a structured hash indicating pending status (as per FR2.2 recommendation)
    # Requires CheckJobStatusTool to be exposed separately via MCP.
    { status: 'pending', job_id: job_id, message: message }
  else
    unknown_status_msg = "Legate tool '#{self.class.tool_name}' returned unknown status: #{result_hash[:status]}"
    Mcp.logger.error(unknown_status_msg)
    raise StandardError, unknown_status_msg
  end
end