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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +160 -4
  4. data/CONTRIBUTING.md +4 -2
  5. data/README.md +1 -1
  6. data/bin/asession +1 -1
  7. data/docs/README.md +1086 -331
  8. data/docs/test-mcp-with-ai.md +5 -3
  9. data/lib/aspera/agent/direct.rb +15 -4
  10. data/lib/aspera/agent/factory.rb +2 -1
  11. data/lib/aspera/agent/node.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +1 -1
  13. data/lib/aspera/api/faspex.rb +1 -1
  14. data/lib/aspera/api/node.rb +6 -2
  15. data/lib/aspera/ascp/installation.rb +47 -11
  16. data/lib/aspera/ascp/management.rb +89 -43
  17. data/lib/aspera/cli/ascp_actions.rb +5 -19
  18. data/lib/aspera/cli/bootstrapper.rb +1 -2
  19. data/lib/aspera/cli/command_registry.rb +43 -3
  20. data/lib/aspera/cli/command_spec.rb +3 -0
  21. data/lib/aspera/cli/completion/ascli.bash +39 -5
  22. data/lib/aspera/cli/completion/ascli.fish +16 -8
  23. data/lib/aspera/cli/completion/ascli.ps1 +41 -0
  24. data/lib/aspera/cli/completion/ascli.zsh +20 -4
  25. data/lib/aspera/cli/completion_actions.rb +360 -0
  26. data/lib/aspera/cli/error.rb +42 -0
  27. data/lib/aspera/cli/extended_value.rb +30 -5
  28. data/lib/aspera/cli/formatter.rb +118 -56
  29. data/lib/aspera/cli/hints.rb +0 -1
  30. data/lib/aspera/cli/http.rb +6 -5
  31. data/lib/aspera/cli/info.rb +10 -4
  32. data/lib/aspera/cli/mailer.rb +1 -1
  33. data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
  34. data/lib/aspera/cli/mcp/secrets.rb +143 -0
  35. data/lib/aspera/cli/mcp/tool.rb +231 -0
  36. data/lib/aspera/cli/option_value.rb +9 -1
  37. data/lib/aspera/cli/options.schema.yaml +390 -39
  38. data/lib/aspera/cli/parser.rb +14 -5
  39. data/lib/aspera/cli/plugins/aoc.rb +8 -11
  40. data/lib/aspera/cli/plugins/base.rb +36 -15
  41. data/lib/aspera/cli/plugins/config.rb +15 -44
  42. data/lib/aspera/cli/plugins/console.rb +3 -1
  43. data/lib/aspera/cli/plugins/factory.rb +1 -1
  44. data/lib/aspera/cli/plugins/faspex5.rb +9 -19
  45. data/lib/aspera/cli/plugins/faspio.rb +36 -5
  46. data/lib/aspera/cli/plugins/httpgw.rb +1 -0
  47. data/lib/aspera/cli/plugins/mcp.rb +177 -118
  48. data/lib/aspera/cli/plugins/node.rb +14 -8
  49. data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
  50. data/lib/aspera/cli/plugins/server.rb +3 -1
  51. data/lib/aspera/cli/plugins/shares.rb +205 -35
  52. data/lib/aspera/cli/preset_actions.rb +15 -7
  53. data/lib/aspera/cli/preset_manager.rb +24 -2
  54. data/lib/aspera/cli/prompt.rb +3 -1
  55. data/lib/aspera/cli/result.rb +17 -4
  56. data/lib/aspera/cli/runner.rb +133 -43
  57. data/lib/aspera/cli/security.rb +134 -0
  58. data/lib/aspera/cli/table_fitter.rb +116 -0
  59. data/lib/aspera/cli/terminal_formatter.rb +23 -34
  60. data/lib/aspera/cli/transfer_agent.rb +4 -1
  61. data/lib/aspera/cli/vault_manager.rb +6 -5
  62. data/lib/aspera/cli/version.rb +1 -1
  63. data/lib/aspera/cli/web_secret.html.erb +61 -0
  64. data/lib/aspera/cli/web_secret.rb +220 -0
  65. data/lib/aspera/cli/wizard.rb +19 -9
  66. data/lib/aspera/command_line_builder.rb +26 -0
  67. data/lib/aspera/data_repository.rb +38 -24
  68. data/lib/aspera/duration.rb +42 -0
  69. data/lib/aspera/environment.rb +88 -24
  70. data/lib/aspera/exec_spec.rb +6 -5
  71. data/lib/aspera/faspex_gw.rb +98 -32
  72. data/lib/aspera/faspex_postproc.rb +187 -44
  73. data/lib/aspera/formatter_interface.rb +0 -1
  74. data/lib/aspera/id_generator.rb +2 -1
  75. data/lib/aspera/json_rpc/client.rb +51 -32
  76. data/lib/aspera/json_rpc/version.rb +1 -0
  77. data/lib/aspera/keychain/base.rb +5 -0
  78. data/lib/aspera/keychain/encrypted_hash.rb +6 -2
  79. data/lib/aspera/keychain/factory.rb +2 -3
  80. data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
  81. data/lib/aspera/keychain/macos_security.rb +119 -13
  82. data/lib/aspera/keychain/one_password_api.rb +8 -3
  83. data/lib/aspera/keychain/one_password_base.rb +14 -9
  84. data/lib/aspera/keychain/one_password_cli.rb +4 -2
  85. data/lib/aspera/log.rb +3 -6
  86. data/lib/aspera/markdown.rb +152 -115
  87. data/lib/aspera/mime.rb +6 -0
  88. data/lib/aspera/nagios.rb +3 -1
  89. data/lib/aspera/node_emulator.rb +98 -101
  90. data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
  91. data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
  92. data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
  93. data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
  94. data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
  95. data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
  96. data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
  97. data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
  98. data/lib/aspera/oauth/server/authorize.html.erb +34 -0
  99. data/lib/aspera/oauth/server/pkce.rb +426 -0
  100. data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
  101. data/lib/aspera/preview/terminal.rb +3 -2
  102. data/lib/aspera/rainbow.rb +27 -1
  103. data/lib/aspera/rest/aspera_errors.rb +51 -59
  104. data/lib/aspera/rest/client.rb +21 -14
  105. data/lib/aspera/rest/error_analyzer.rb +9 -0
  106. data/lib/aspera/rest/list.rb +6 -6
  107. data/lib/aspera/rest/util.rb +7 -1
  108. data/lib/aspera/rest.rb +0 -1
  109. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
  110. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
  111. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
  113. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
  114. data/lib/aspera/schema/documentation.rb +50 -78
  115. data/lib/aspera/schema/reader.rb +39 -11
  116. data/lib/aspera/schema/registry.rb +8 -2
  117. data/lib/aspera/secret_hider.rb +94 -15
  118. data/lib/aspera/secret_strength.rb +114 -0
  119. data/lib/aspera/servlet_helpers.rb +111 -0
  120. data/lib/aspera/sync/args.schema.yaml +3 -1
  121. data/lib/aspera/sync/conf.schema.yaml +16 -6
  122. data/lib/aspera/sync/operations.rb +21 -1
  123. data/lib/aspera/temp_file_manager.rb +79 -20
  124. data/lib/aspera/transfer/parameters.rb +5 -0
  125. data/lib/aspera/transfer/spec.rb +52 -12
  126. data/lib/aspera/transfer/spec.schema.yaml +8 -5
  127. data/lib/aspera/web_auth/index.html.erb +16 -0
  128. data/lib/aspera/web_auth/server.rb +18 -10
  129. data/lib/aspera/web_page.html.erb +262 -0
  130. data/lib/aspera/web_page.rb +61 -0
  131. data/lib/aspera/web_server_simple.rb +76 -10
  132. data.tar.gz.sig +0 -0
  133. metadata +47 -32
  134. metadata.gz.sig +0 -0
  135. data/lib/aspera/cli/mcp_tool.rb +0 -189
  136. data/lib/aspera/oauth.rb +0 -9
  137. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
  138. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
  139. 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
- mergeable = current_value.is_a?(new_value.is_a?(Hash) ? Hash : Array) && !current_value.empty?
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)