aspera-cli 4.27.5 → 4.28.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 +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +160 -4
- data/CONTRIBUTING.md +4 -2
- data/README.md +1 -1
- data/bin/asession +1 -1
- data/docs/README.md +1086 -331
- data/docs/test-mcp-with-ai.md +5 -3
- data/lib/aspera/agent/direct.rb +15 -4
- data/lib/aspera/agent/factory.rb +2 -1
- data/lib/aspera/agent/node.rb +1 -1
- data/lib/aspera/api/cos_node.rb +1 -1
- data/lib/aspera/api/faspex.rb +1 -1
- data/lib/aspera/api/node.rb +6 -2
- data/lib/aspera/ascp/installation.rb +47 -11
- data/lib/aspera/ascp/management.rb +89 -43
- data/lib/aspera/cli/ascp_actions.rb +5 -19
- data/lib/aspera/cli/bootstrapper.rb +1 -2
- data/lib/aspera/cli/command_registry.rb +43 -3
- data/lib/aspera/cli/command_spec.rb +3 -0
- data/lib/aspera/cli/completion/ascli.bash +39 -5
- data/lib/aspera/cli/completion/ascli.fish +16 -8
- data/lib/aspera/cli/completion/ascli.ps1 +41 -0
- data/lib/aspera/cli/completion/ascli.zsh +20 -4
- data/lib/aspera/cli/completion_actions.rb +360 -0
- data/lib/aspera/cli/error.rb +42 -0
- data/lib/aspera/cli/extended_value.rb +30 -5
- data/lib/aspera/cli/formatter.rb +118 -56
- data/lib/aspera/cli/hints.rb +0 -1
- data/lib/aspera/cli/http.rb +6 -5
- data/lib/aspera/cli/info.rb +10 -4
- data/lib/aspera/cli/mailer.rb +1 -1
- data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
- data/lib/aspera/cli/mcp/secrets.rb +143 -0
- data/lib/aspera/cli/mcp/tool.rb +231 -0
- data/lib/aspera/cli/option_value.rb +9 -1
- data/lib/aspera/cli/options.schema.yaml +390 -39
- data/lib/aspera/cli/parser.rb +14 -5
- data/lib/aspera/cli/plugins/aoc.rb +8 -11
- data/lib/aspera/cli/plugins/base.rb +36 -15
- data/lib/aspera/cli/plugins/config.rb +15 -44
- data/lib/aspera/cli/plugins/console.rb +3 -1
- data/lib/aspera/cli/plugins/factory.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +9 -19
- data/lib/aspera/cli/plugins/faspio.rb +36 -5
- data/lib/aspera/cli/plugins/httpgw.rb +1 -0
- data/lib/aspera/cli/plugins/mcp.rb +177 -118
- data/lib/aspera/cli/plugins/node.rb +14 -8
- data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
- data/lib/aspera/cli/plugins/server.rb +3 -1
- data/lib/aspera/cli/plugins/shares.rb +205 -35
- data/lib/aspera/cli/preset_actions.rb +15 -7
- data/lib/aspera/cli/preset_manager.rb +24 -2
- data/lib/aspera/cli/prompt.rb +3 -1
- data/lib/aspera/cli/result.rb +17 -4
- data/lib/aspera/cli/runner.rb +133 -43
- data/lib/aspera/cli/security.rb +134 -0
- data/lib/aspera/cli/table_fitter.rb +116 -0
- data/lib/aspera/cli/terminal_formatter.rb +23 -34
- data/lib/aspera/cli/transfer_agent.rb +4 -1
- data/lib/aspera/cli/vault_manager.rb +6 -5
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/web_secret.html.erb +61 -0
- data/lib/aspera/cli/web_secret.rb +220 -0
- data/lib/aspera/cli/wizard.rb +19 -9
- data/lib/aspera/command_line_builder.rb +26 -0
- data/lib/aspera/data_repository.rb +38 -24
- data/lib/aspera/duration.rb +42 -0
- data/lib/aspera/environment.rb +88 -24
- data/lib/aspera/exec_spec.rb +6 -5
- data/lib/aspera/faspex_gw.rb +98 -32
- data/lib/aspera/faspex_postproc.rb +187 -44
- data/lib/aspera/formatter_interface.rb +0 -1
- data/lib/aspera/id_generator.rb +2 -1
- data/lib/aspera/json_rpc/client.rb +51 -32
- data/lib/aspera/json_rpc/version.rb +1 -0
- data/lib/aspera/keychain/base.rb +5 -0
- data/lib/aspera/keychain/encrypted_hash.rb +6 -2
- data/lib/aspera/keychain/factory.rb +2 -3
- data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
- data/lib/aspera/keychain/macos_security.rb +119 -13
- data/lib/aspera/keychain/one_password_api.rb +8 -3
- data/lib/aspera/keychain/one_password_base.rb +14 -9
- data/lib/aspera/keychain/one_password_cli.rb +4 -2
- data/lib/aspera/log.rb +3 -6
- data/lib/aspera/markdown.rb +152 -115
- data/lib/aspera/mime.rb +6 -0
- data/lib/aspera/nagios.rb +3 -1
- data/lib/aspera/node_emulator.rb +98 -101
- data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
- data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
- data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
- data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
- data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
- data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
- data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
- data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
- data/lib/aspera/oauth/server/authorize.html.erb +34 -0
- data/lib/aspera/oauth/server/pkce.rb +426 -0
- data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/rainbow.rb +27 -1
- data/lib/aspera/rest/aspera_errors.rb +51 -59
- data/lib/aspera/rest/client.rb +21 -14
- data/lib/aspera/rest/error_analyzer.rb +9 -0
- data/lib/aspera/rest/list.rb +6 -6
- data/lib/aspera/rest/util.rb +7 -1
- data/lib/aspera/rest.rb +0 -1
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
- data/lib/aspera/schema/documentation.rb +50 -78
- data/lib/aspera/schema/reader.rb +39 -11
- data/lib/aspera/schema/registry.rb +8 -2
- data/lib/aspera/secret_hider.rb +94 -15
- data/lib/aspera/secret_strength.rb +114 -0
- data/lib/aspera/servlet_helpers.rb +111 -0
- data/lib/aspera/sync/args.schema.yaml +3 -1
- data/lib/aspera/sync/conf.schema.yaml +16 -6
- data/lib/aspera/sync/operations.rb +21 -1
- data/lib/aspera/temp_file_manager.rb +79 -20
- data/lib/aspera/transfer/parameters.rb +5 -0
- data/lib/aspera/transfer/spec.rb +52 -12
- data/lib/aspera/transfer/spec.schema.yaml +8 -5
- data/lib/aspera/web_auth/index.html.erb +16 -0
- data/lib/aspera/web_auth/server.rb +18 -10
- data/lib/aspera/web_page.html.erb +262 -0
- data/lib/aspera/web_page.rb +61 -0
- data/lib/aspera/web_server_simple.rb +76 -10
- data.tar.gz.sig +0 -0
- metadata +47 -32
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/mcp_tool.rb +0 -189
- data/lib/aspera/oauth.rb +0 -9
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
- data/lib/aspera/web_auth/index.html +0 -147
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/secret_hider'
|
|
4
|
+
require 'aspera/cli/error'
|
|
5
|
+
require 'aspera/cli/info'
|
|
6
|
+
require 'aspera/cli/web_secret'
|
|
7
|
+
require 'json'
|
|
8
|
+
|
|
9
|
+
module Aspera
|
|
10
|
+
module Cli
|
|
11
|
+
module Mcp
|
|
12
|
+
# Secrets of extended value `@secret:` in MCP server mode, entered by the user on a web page (see WebSecret).
|
|
13
|
+
# The MCP client opens the page following a URL mode elicitation, so that the secret never passes through
|
|
14
|
+
# the MCP client or the AI model.
|
|
15
|
+
# See: https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation
|
|
16
|
+
module Secrets
|
|
17
|
+
# Tag thrown, with the prompt, when a tool call needs a secret not requested yet
|
|
18
|
+
THROW_TAG = :mcp_secret_required
|
|
19
|
+
# Fiber-local variable holding the secrets of the tool call in progress
|
|
20
|
+
CALL_KEY = :aspera_mcp_secret_call
|
|
21
|
+
private_constant :CALL_KEY
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
# @param base_url [String, nil] URL of the MCP server, `nil` to start a local web server on first request
|
|
25
|
+
# @param access_code [String, nil] If set, the user must also enter it on the page (`auth_token` of the server)
|
|
26
|
+
# @return [WebSecret] Secret pages of the MCP server
|
|
27
|
+
def web_secret(base_url: nil, access_code: nil)
|
|
28
|
+
WebSecret.new(base_url: base_url, access_code: access_code, name: "#{Info::CMD_NAME} MCP server", requester: 'AI assistant')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Value of extended value `@secret:` for the tool call in progress
|
|
32
|
+
# @param prompt [String] Name of the secret
|
|
33
|
+
# @return [String] The secret
|
|
34
|
+
def value(prompt)
|
|
35
|
+
call = Thread.current[CALL_KEY]
|
|
36
|
+
raise BadArgument, '@secret: is available only in MCP tool calls' if call.nil?
|
|
37
|
+
call.value(prompt.empty? ? 'secret' : prompt)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Execute the block with the secrets of the given tool call available to `@secret:`
|
|
41
|
+
# @param call [Call] Secrets of the tool call
|
|
42
|
+
def with_call(call)
|
|
43
|
+
previous = Thread.current[CALL_KEY]
|
|
44
|
+
Thread.current[CALL_KEY] = call
|
|
45
|
+
yield
|
|
46
|
+
ensure
|
|
47
|
+
Thread.current[CALL_KEY] = previous
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Secrets of one tool call.
|
|
52
|
+
# Following MCP multi round-trip requests, the tool call is executed again from the start on each round:
|
|
53
|
+
# `@secret:` values are taken in order from the requests of previous rounds, listed in the request state.
|
|
54
|
+
class Call
|
|
55
|
+
# @param web_secret [WebSecret, nil] Secret pages, `nil` if `@secret:` is not available
|
|
56
|
+
# @param server_context [MCP::ServerContext, nil] Context of the tool call
|
|
57
|
+
def initialize(web_secret, server_context)
|
|
58
|
+
@web_secret = web_secret
|
|
59
|
+
@server_context = server_context
|
|
60
|
+
@ids = request_ids(server_context&.request_state)
|
|
61
|
+
@next = 0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param prompt [String] Name of the secret
|
|
65
|
+
# @return [String] The secret entered by the user, waiting for it if needed
|
|
66
|
+
# @throw THROW_TAG with the prompt if the secret was not requested yet
|
|
67
|
+
def value(prompt)
|
|
68
|
+
raise BadArgument, '@secret: is not available' if @web_secret.nil?
|
|
69
|
+
throw(THROW_TAG, prompt) if @next >= @ids.size
|
|
70
|
+
id = @ids[@next]
|
|
71
|
+
@next += 1
|
|
72
|
+
# The command is the same on each round, so prompts come in the same order
|
|
73
|
+
raise BadArgument, 'Secret request expired or command changed: execute the command again' unless @web_secret.prompt(id).eql?(prompt)
|
|
74
|
+
action = input_action(id)
|
|
75
|
+
raise Error, "Secret request refused in MCP client: #{action}" unless action.nil? || action.eql?('accept')
|
|
76
|
+
value = @web_secret.wait_value(id) do |elapsed|
|
|
77
|
+
@server_context&.report_progress(elapsed.to_i, message: "Waiting for secret #{prompt} entered in browser")
|
|
78
|
+
end
|
|
79
|
+
# Clients of protocol 2025-11-25: completion of the out-of-band interaction
|
|
80
|
+
@server_context.notify_elicitation_complete(elicitation_id: id) unless action.nil? || @server_context.modern?
|
|
81
|
+
# Never reaches the client, even if displayed by the command
|
|
82
|
+
SecretHider.instance.add_secret_values(value)
|
|
83
|
+
value
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Boolean] `true` if the MCP client supports URL mode elicitation
|
|
87
|
+
def url_elicitation?
|
|
88
|
+
!@web_secret.nil? && !read_key(read_key(@server_context&.client_capabilities, :elicitation), :url).nil?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Request a new secret: the client asks the user to open the page, then executes the tool call again
|
|
92
|
+
# @param prompt [String] Name of the secret
|
|
93
|
+
# @return [MCP::Server::InputRequiredResult]
|
|
94
|
+
def input_required(prompt)
|
|
95
|
+
id = @web_secret.create(prompt)
|
|
96
|
+
@ids.push(id)
|
|
97
|
+
params = {
|
|
98
|
+
mode: 'url',
|
|
99
|
+
url: @web_secret.url(id),
|
|
100
|
+
message: "#{Info::CMD_NAME} needs secret \"#{prompt}\" to execute the command. " \
|
|
101
|
+
'Enter it on the web page: it is sent to the MCP server only, not to the AI assistant.'
|
|
102
|
+
}
|
|
103
|
+
# Clients of protocol 2025-11-25: identifier of the out-of-band interaction
|
|
104
|
+
params[:elicitationId] = id unless @server_context.modern?
|
|
105
|
+
MCP::Server::InputRequiredResult.new(
|
|
106
|
+
input_requests: {id => {method: 'elicitation/create', params: params}},
|
|
107
|
+
request_state: JSON.generate(@ids)
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Forget the secrets of this call
|
|
112
|
+
def finish
|
|
113
|
+
@web_secret&.delete(@ids)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# @param state [String, nil] Request state echoed by the client (unsealed by the MCP server)
|
|
119
|
+
# @return [Array<String>] Identifiers of the requests of previous rounds
|
|
120
|
+
def request_ids(state)
|
|
121
|
+
return [] if state.nil?
|
|
122
|
+
ids = JSON.parse(state) rescue nil
|
|
123
|
+
raise BadArgument, 'Invalid request state' unless ids.is_a?(Array) && ids.all?(String)
|
|
124
|
+
ids
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# @param id [String] Identifier of the request
|
|
128
|
+
# @return [String, nil] Action of the user in the MCP client (`accept`, `decline`, `cancel`), `nil` if not in this round
|
|
129
|
+
def input_action(id)
|
|
130
|
+
response = @server_context&.input_response(id)
|
|
131
|
+
read_key(response, :action)&.to_s
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Read a key of a JSON object, parsed with or without symbolized keys
|
|
135
|
+
def read_key(hash, key)
|
|
136
|
+
return unless hash.is_a?(Hash)
|
|
137
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# cspell:ignore ascli
|
|
4
|
+
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'aspera/log'
|
|
7
|
+
require 'aspera/secret_hider'
|
|
8
|
+
require 'aspera/cli/runner'
|
|
9
|
+
require 'aspera/cli/error'
|
|
10
|
+
require 'aspera/cli/mcp/secrets'
|
|
11
|
+
require 'aspera/schema/reader'
|
|
12
|
+
require 'aspera/schema/registry'
|
|
13
|
+
require 'aspera/cli/plugins/mcp'
|
|
14
|
+
unless defined?(MCP::Tool)
|
|
15
|
+
begin
|
|
16
|
+
require 'mcp'
|
|
17
|
+
rescue LoadError
|
|
18
|
+
raise Cli::Error, "The 'mcp' and 'rack' gems are required. Install them with: gem install mcp rack"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
module Aspera
|
|
23
|
+
module Cli
|
|
24
|
+
module Mcp
|
|
25
|
+
# MCP Tool: executes an ascli command in-process.
|
|
26
|
+
class Tool < MCP::Tool
|
|
27
|
+
# Default maximum byte size of the JSON text content returned for list results.
|
|
28
|
+
# Items are appended whole until the limit is reached; the full list is always
|
|
29
|
+
# available in structuredContent.
|
|
30
|
+
DEFAULT_MAX_TEXT_BYTES = 100_000
|
|
31
|
+
# Default extra arguments automatically prepended to every ascli call.
|
|
32
|
+
# Keeps the AI from having to remember mandatory flags on every invocation.
|
|
33
|
+
DEFAULT_EXTRA_ARGS = ['--interactive=no', '--transfer.asynchronous=true'].freeze
|
|
34
|
+
|
|
35
|
+
tool_name 'execute_ascli_command'
|
|
36
|
+
|
|
37
|
+
description <<~DESC.strip
|
|
38
|
+
Execute any ascli (Aspera CLI) command in-process and return its result.
|
|
39
|
+
|
|
40
|
+
#{Plugins::Mcp::COMMAND_DISPLAY_INSTRUCTIONS}
|
|
41
|
+
|
|
42
|
+
SYNTAX
|
|
43
|
+
args is a JSON array of strings mirroring the CLI command line.
|
|
44
|
+
Element 0 : plugin name (aoc, faspex5, node, server, config, …).
|
|
45
|
+
Elements 1+: sub-commands, positional arguments, and --option=value flags in any order.
|
|
46
|
+
Structured values use an extended-value prefix on the relevant element:
|
|
47
|
+
"@json:{...}" — inline JSON object or array (preferred: no shell quoting)
|
|
48
|
+
"@preset:name" — expand a saved preset
|
|
49
|
+
"@base64:..." — base64-encoded value
|
|
50
|
+
"@val:..." — literal string (no further decoding)
|
|
51
|
+
"@secret:name" — secret entered by the user on a web page, never visible to you:
|
|
52
|
+
use it for a secret the user did not give, e.g. "--password=@secret:password"
|
|
53
|
+
Note: @stdin: is disabled; @ruby:, @file:, @uri:, @env: depend on the security level of the server.
|
|
54
|
+
A secret (from @secret:, @vault:, @preset:) must be the whole value: no other prefix before it, not in @extend:.
|
|
55
|
+
|
|
56
|
+
AUTOMATIC FLAGS
|
|
57
|
+
The server prepends extra_args to every call (default: #{DEFAULT_EXTRA_ARGS.join(' ')}).
|
|
58
|
+
Do NOT repeat them. To override one, include it in args: later values take precedence.
|
|
59
|
+
Commands never prompt: if credentials are missing, an error is returned; report it and stop.
|
|
60
|
+
|
|
61
|
+
DISCOVERY — never guess command or field names from training data
|
|
62
|
+
Step 1 — commands of a plugin, with syntax:
|
|
63
|
+
["config", "commands", "<plugin>"]
|
|
64
|
+
Returns { syntax, description } for every command of that plugin.
|
|
65
|
+
Notation: <arg> mandatory, [<arg>] optional, <a|b> enum, <arg...> variadic, <arg:Hash> typed.
|
|
66
|
+
A line ending with <command...> and "(see: <plugin> <path>)" provides the commands of that path:
|
|
67
|
+
list them with ["config", "commands", "<plugin>", "<command>", ...] (command words only).
|
|
68
|
+
Omit <plugin> to list the commands of all plugins (large).
|
|
69
|
+
Step 2 — schema of a Hash argument (shown <name:Hash>), MANDATORY before calling such a command:
|
|
70
|
+
["<plugin>", "<cmd>", ..., "help"]
|
|
71
|
+
Put the literal "help" in place of the Hash argument. Returns field names, types,
|
|
72
|
+
required flags and descriptions. Never infer fields from server error messages.
|
|
73
|
+
Step 2b — filter parameters of a list command:
|
|
74
|
+
["<plugin>", "<cmd>", ..., "--query=help"]
|
|
75
|
+
Step 3 — all --flags of a plugin, with allowed values:
|
|
76
|
+
["config", "options", "<plugin>"]
|
|
77
|
+
Documentation:
|
|
78
|
+
["config", "documentation", "toc"] → { level, title, anchor } per heading
|
|
79
|
+
["config", "documentation", "local", "<anchor>"] → one README section
|
|
80
|
+
Presets (saved credentials):
|
|
81
|
+
["config", "preset", "overview"]
|
|
82
|
+
|
|
83
|
+
RESULT FORMAT
|
|
84
|
+
Structured data is always in structuredContent (a JSON object; lists are under "items").
|
|
85
|
+
For lists, text content is limited to #{DEFAULT_MAX_TEXT_BYTES} bytes (whole items only);
|
|
86
|
+
when truncated, a WARNING line gives the real total. Read structuredContent.items for
|
|
87
|
+
the full dataset — never report counts or search results from a truncated text block.
|
|
88
|
+
|
|
89
|
+
FILE LIST FOR TRANSFERS
|
|
90
|
+
For all transfers (upload, download, package send, …), append source paths at the
|
|
91
|
+
end of args — no --sources flag needed.
|
|
92
|
+
|
|
93
|
+
EXAMPLES
|
|
94
|
+
["config", "commands", "aoc"]
|
|
95
|
+
["aoc", "admin", "user", "create", "help"]
|
|
96
|
+
["config", "options", "aoc"]
|
|
97
|
+
["config", "documentation", "local", "leveraging-ai-assistance"]
|
|
98
|
+
["aoc", "--preset=myaoc", "packages", "list"]
|
|
99
|
+
["aoc", "--preset=myaoc", "admin", "user", "create", '@json:{"email":"a@b.com","name":"Alice"}']
|
|
100
|
+
["aoc", "--preset=myaoc", "packages", "send",
|
|
101
|
+
'@json:{"name":"pkg","recipients":["user@example.com"]}', "/local/a.txt", "/local/b.txt"]
|
|
102
|
+
["server", "--preset=myserver", "upload", "--to-folder=/uploads", "/local/a.txt"]
|
|
103
|
+
["server", "browse", "/", "--url=ssh://host:33001", "--username=user", "--password=secret"]
|
|
104
|
+
DESC
|
|
105
|
+
|
|
106
|
+
input_schema(
|
|
107
|
+
properties: {
|
|
108
|
+
args: {
|
|
109
|
+
type: 'array',
|
|
110
|
+
items: {type: 'string'},
|
|
111
|
+
minItems: 1,
|
|
112
|
+
description: 'ascli arguments: first element is the plugin name, followed by action and --option=value flags'
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
required: ['args']
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
class << self
|
|
119
|
+
attr_accessor :max_text_bytes, :extra_args
|
|
120
|
+
# @return [WebSecret, nil] Secret pages for extended value `@secret:`
|
|
121
|
+
attr_accessor :web_secret
|
|
122
|
+
|
|
123
|
+
# Executes the command.
|
|
124
|
+
# If it needs a secret (`@secret:`), returns an `input_required` result: the client opens the page where the user
|
|
125
|
+
# enters the secret, then calls the tool again, and the command is executed again with the secret.
|
|
126
|
+
def call(args:, server_context: nil)
|
|
127
|
+
secret_call = Secrets::Call.new(web_secret, server_context)
|
|
128
|
+
prompt = catch(Secrets::THROW_TAG) do
|
|
129
|
+
response = Secrets.with_call(secret_call) { execute(args) }
|
|
130
|
+
secret_call.finish
|
|
131
|
+
# Secret values (e.g. entered by the user, tokens) never reach the client, even if displayed by the command,
|
|
132
|
+
# also short ones in a text (e.g. a short password in an error message)
|
|
133
|
+
hider = SecretHider.instance
|
|
134
|
+
return MCP::Tool::Response.new(
|
|
135
|
+
hider.hidden_copy(response.content, short_in_text: true),
|
|
136
|
+
structured_content: hider.hidden_copy(response.structured_content, short_in_text: true),
|
|
137
|
+
error: response.error?
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
return secret_call.input_required(prompt) if secret_call.url_elicitation?
|
|
141
|
+
secret_call.finish
|
|
142
|
+
MCP::Tool::Response.new([{type: 'text', text: "Extended value @secret:#{prompt} requires an MCP client supporting URL mode elicitation"}], error: true)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Executes the command in-process
|
|
146
|
+
# Unsafe extended values are restricted when evaluated, see Security
|
|
147
|
+
# @param args [Array<String>] Arguments of the command
|
|
148
|
+
# @return [MCP::Tool::Response]
|
|
149
|
+
def execute(args)
|
|
150
|
+
effective_args = Array(extra_args || DEFAULT_EXTRA_ARGS) + args
|
|
151
|
+
Log.dump(:mcp_execute, effective_args)
|
|
152
|
+
runner = Runner.new(effective_args)
|
|
153
|
+
result = runner.run_with_result
|
|
154
|
+
# Secrets never reach the AI client, whatever the output format requested in args.
|
|
155
|
+
# As on the command line, `--out.query` applies to the data of an object or list, after secrets are removed
|
|
156
|
+
if result.is_a?(Result::SingleObject) || result.is_a?(Result::ObjectList) || result.is_a?(Result::ValueList)
|
|
157
|
+
result = result.with_data(SecretHider.instance.hidden_copy(result.data))
|
|
158
|
+
result = runner.context.formatter.query_result(result)
|
|
159
|
+
end
|
|
160
|
+
case result
|
|
161
|
+
when Result::Nothing, Result::Empty, NilClass
|
|
162
|
+
MCP::Tool::Response.new([{type: 'text', text: ''}])
|
|
163
|
+
when Result::SingleObject, Result::ObjectList, Result::ValueList
|
|
164
|
+
# Apply --select filter (affects both text and structuredContent).
|
|
165
|
+
result = result.with_data(runner.context.formatter.filter_columns_on_select(result.data)) if result.data.is_a?(Array)
|
|
166
|
+
# MCP spec requires structuredContent to be a JSON object (not an array).
|
|
167
|
+
structured = result.data.is_a?(Array) ? {items: result.data} : result.data
|
|
168
|
+
content = if result.data.is_a?(Array)
|
|
169
|
+
text_limit = max_text_bytes || DEFAULT_MAX_TEXT_BYTES
|
|
170
|
+
truncated_items = truncate_items_by_bytes(result.data, text_limit)
|
|
171
|
+
total = result.data.size
|
|
172
|
+
if truncated_items.size < total
|
|
173
|
+
[
|
|
174
|
+
{type: 'text', text: JSON.generate(truncated_items)},
|
|
175
|
+
{type: 'text', text: "WARNING: result truncated to #{truncated_items.size} of #{total} items. Full dataset available in structuredContent."}
|
|
176
|
+
]
|
|
177
|
+
else
|
|
178
|
+
[{type: 'text', text: JSON.generate(result.data)}]
|
|
179
|
+
end
|
|
180
|
+
else
|
|
181
|
+
[{type: 'text', text: JSON.generate(result.data)}]
|
|
182
|
+
end
|
|
183
|
+
MCP::Tool::Response.new(content, structured_content: structured)
|
|
184
|
+
else
|
|
185
|
+
MCP::Tool::Response.new([{type: 'text', text: SecretHider.instance.hide_secrets_in_string(result.data.to_s, all: true)}])
|
|
186
|
+
end
|
|
187
|
+
rescue Cli::SchemaRequest => e
|
|
188
|
+
schema_path = e.path
|
|
189
|
+
if schema_path.nil?
|
|
190
|
+
MCP::Tool::Response.new([{type: 'text', text: "#{e.class}: #{e.message} (no schema available)"}], error: true)
|
|
191
|
+
else
|
|
192
|
+
rows = schema_to_rows(Schema::Registry.instance.reader(schema_path))
|
|
193
|
+
structured = {items: rows}
|
|
194
|
+
MCP::Tool::Response.new([{type: 'text', text: JSON.generate(rows)}], structured_content: structured)
|
|
195
|
+
end
|
|
196
|
+
rescue SystemExit => e
|
|
197
|
+
MCP::Tool::Response.new([{type: 'text', text: "exited with status #{e.status}"}], error: !e.status.zero?)
|
|
198
|
+
rescue => e
|
|
199
|
+
MCP::Tool::Response.new([{type: 'text', text: SecretHider.instance.hide_secrets_in_string("#{e.class}: #{dedupe_lines(e.message)}", all: true)}], error: true)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Delegate to Schema::Reader#to_rows — semantic fields, no ANSI, MCP/JSON-ready.
|
|
203
|
+
def schema_to_rows(reader)
|
|
204
|
+
reader.to_rows
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Collapse consecutive duplicate lines in a multi-line message.
|
|
208
|
+
# Each run of identical lines is replaced by one line + "(×N)" suffix when N > 1.
|
|
209
|
+
def dedupe_lines(msg)
|
|
210
|
+
return msg unless msg.include?("\n")
|
|
211
|
+
msg.split("\n").chunk_while { |a, b| a == b }.map do |group|
|
|
212
|
+
group.size > 1 ? "#{group.first} (x#{group.size})" : group.first
|
|
213
|
+
end.join("\n")
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Returns the largest prefix of `items` whose JSON serialization fits within `max_bytes`.
|
|
217
|
+
# Items are appended whole — no item is ever split mid-JSON.
|
|
218
|
+
def truncate_items_by_bytes(items, max_bytes)
|
|
219
|
+
buf = +''
|
|
220
|
+
items.each_with_index do |item, i|
|
|
221
|
+
fragment = (i.zero? ? '[' : ',') + JSON.generate(item)
|
|
222
|
+
break if buf.bytesize + fragment.bytesize + 1 > max_bytes # +1 for closing ']'
|
|
223
|
+
buf << fragment
|
|
224
|
+
end
|
|
225
|
+
buf.empty? ? [] : JSON.parse("#{buf}]")
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -134,7 +134,9 @@ module Aspera
|
|
|
134
134
|
Aspera.assert_type(new_value, *@types, type: BadArgument) { "Option #{@option}" } if @types
|
|
135
135
|
if merge && (new_value.is_a?(Hash) || new_value.is_a?(Array))
|
|
136
136
|
current_value = @value
|
|
137
|
-
|
|
137
|
+
# An `Array` default value is replaced, not appended to
|
|
138
|
+
mergeable = current_value.is_a?(new_value.is_a?(Hash) ? Hash : Array) && !current_value.empty? &&
|
|
139
|
+
!(new_value.is_a?(Array) && [source, @source].include?(:default))
|
|
138
140
|
if lower
|
|
139
141
|
# Current value has priority: merge new value under it, unless explicitly emptied
|
|
140
142
|
return unless mergeable
|
|
@@ -269,6 +271,12 @@ module Aspera
|
|
|
269
271
|
# @param new_value [Object] value to store
|
|
270
272
|
# @param source [Symbol, nil] `OptionSource` of value
|
|
271
273
|
def store(new_value, source)
|
|
274
|
+
# Secret values are hidden wherever they appear: logs, results
|
|
275
|
+
if @sensitive
|
|
276
|
+
SecretHider.instance.add_secret_values(new_value)
|
|
277
|
+
else
|
|
278
|
+
SecretHider.instance.add_secrets_in(new_value)
|
|
279
|
+
end
|
|
272
280
|
@value = new_value
|
|
273
281
|
@source = source
|
|
274
282
|
@on_set&.call(new_value)
|