Class: Aspera::Cli::McpTool

Inherits:
MCP::Tool
  • Object
show all
Defined in:
lib/aspera/cli/mcp_tool.rb

Overview

MCP Tool: executes an ascli command in-process.

Constant Summary collapse

DEFAULT_MAX_TEXT_BYTES =

Default maximum byte size of the JSON text content returned for list results. Items are appended whole until the limit is reached; the full list is always available in structuredContent.

100_000
DEFAULT_EXTRA_ARGS =

Default extra arguments automatically prepended to every ascli call. Keeps the AI from having to remember mandatory flags on every invocation.

['--interactive=no', '--transfer.asynchronous=true'].freeze

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.extra_argsObject

Returns the value of attribute extra_args.



148
149
150
# File 'lib/aspera/cli/mcp_tool.rb', line 148

def extra_args
  @extra_args
end

.max_text_bytesObject

Returns the value of attribute max_text_bytes.



148
149
150
# File 'lib/aspera/cli/mcp_tool.rb', line 148

def max_text_bytes
  @max_text_bytes
end

Class Method Details

.call(args:, server_context: nil) ⇒ Object



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/aspera/cli/mcp_tool.rb', line 150

def call(args:, server_context: nil)
  effective_args = Array(extra_args || DEFAULT_EXTRA_ARGS) + args
  Log.dump(:mcp_execute, effective_args)
  runner = Runner.new(effective_args)
  result = runner.run_with_result
  case result
  when Result::Nothing, Result::Empty, NilClass
    MCP::Tool::Response.new([{type: 'text', text: ''}])
  when Result::SingleObject, Result::ObjectList, Result::ValueList
    # Apply --select filter in place (affects both text and structuredContent).
    runner.context.formatter.filter_columns_on_select(result.data) if result.data.is_a?(Array)
    # MCP spec requires structuredContent to be a JSON object (not an array).
    structured = result.data.is_a?(Array) ? {items: result.data} : result.data
    content = if result.data.is_a?(Array)
      text_limit = max_text_bytes || DEFAULT_MAX_TEXT_BYTES
      truncated_items = truncate_items_by_bytes(result.data, text_limit)
      total = result.data.size
      if truncated_items.size < total
        [
          {type: 'text', text: JSON.generate(truncated_items)},
          {type: 'text', text: "WARNING: result truncated to #{truncated_items.size} of #{total} items. Full dataset available in structuredContent."}
        ]
      else
        [{type: 'text', text: JSON.generate(result.data)}]
      end
    else
      [{type: 'text', text: JSON.generate(result.data)}]
    end
    MCP::Tool::Response.new(content, structured_content: structured)
  else
    MCP::Tool::Response.new([{type: 'text', text: result.data.to_s}])
  end
rescue Cli::SchemaRequest => e
  schema_path = e.path
  if schema_path.nil?
    MCP::Tool::Response.new([{type: 'text', text: "#{e.class}: #{e.message} (no schema available)"}], error: true)
  else
    rows = schema_to_rows(Schema::Registry.instance.reader(schema_path))
    structured = {items: rows}
    MCP::Tool::Response.new([{type: 'text', text: JSON.generate(rows)}], structured_content: structured)
  end
rescue SystemExit => e
  MCP::Tool::Response.new([{type: 'text', text: "exited with status #{e.status}"}], error: !e.status.zero?)
rescue => e
  MCP::Tool::Response.new([{type: 'text', text: "#{e.class}: #{dedupe_lines(e.message)}"}], error: true)
end

.dedupe_lines(msg) ⇒ Object

Collapse consecutive duplicate lines in a multi-line message. Each run of identical lines is replaced by one line + "(×N)" suffix when N > 1.



204
205
206
207
208
209
# File 'lib/aspera/cli/mcp_tool.rb', line 204

def dedupe_lines(msg)
  return msg unless msg.include?("\n")
  msg.split("\n").chunk_while { |a, b| a == b }.map do |group|
    group.size > 1 ? "#{group.first} (x#{group.size})" : group.first
  end.join("\n")
end

.schema_to_rows(reader) ⇒ Object

Delegate to Schema::Reader#to_rows — semantic fields, no ANSI, MCP/JSON-ready.



198
199
200
# File 'lib/aspera/cli/mcp_tool.rb', line 198

def schema_to_rows(reader)
  reader.to_rows
end

.truncate_items_by_bytes(items, max_bytes) ⇒ Object

Returns the largest prefix of items whose JSON serialization fits within max_bytes. Items are appended whole — no item is ever split mid-JSON.



213
214
215
216
217
218
219
220
221
# File 'lib/aspera/cli/mcp_tool.rb', line 213

def truncate_items_by_bytes(items, max_bytes)
  buf = +''
  items.each_with_index do |item, i|
    fragment = (i.zero? ? '[' : ',') + JSON.generate(item)
    break if buf.bytesize + fragment.bytesize + 1 > max_bytes # +1 for closing ']'
    buf << fragment
  end
  buf.empty? ? [] : JSON.parse("#{buf}]")
end