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
@@ -1,11 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # cspell:ignore ascli jsonrpc
3
+ # cspell:ignore ascli jsonrpc pkce
4
4
 
5
5
  require 'aspera/cli/plugins/base'
6
6
  require 'aspera/cli/info'
7
7
  require 'aspera/cli/version'
8
- require 'json'
8
+ require 'aspera/oauth/server/pkce'
9
+ require 'aspera/cli/security'
10
+ require 'aspera/cli/deprecation'
11
+ require 'aspera/secret_strength'
12
+ require 'securerandom'
9
13
 
10
14
  module Aspera
11
15
  module Cli
@@ -22,8 +26,17 @@ module Aspera
22
26
  # # stdio transport:
23
27
  # max_line_bytes: Integer - max JSON frame size (default 4 MiB)
24
28
  # # http transport:
25
- # port: Integer - TCP port (default 3000)
26
- # bind: String - bind address (default "127.0.0.1")
29
+ # url: String - address and port the server listens on (default "http://127.0.0.1:3000"), https:// for TLS
30
+ # cert, key, chain: String - TLS certificate, private key or PKCS12 passphrase, chain, see WebServerSimple
31
+ # auth_token: String - if set, require "Authorization: Bearer <token>" on MCP requests,
32
+ # and asked on the OAuth consent page (mandatory when url or public_url is not loopback)
33
+ # auth_token_min_bits: Integer - minimum estimated strength of auth_token in bits (default 80, 0 disables)
34
+ # oauth: Boolean - enable OAuth 2.0 PKCE flow with dynamic client registration (RFC 7591)
35
+ # oauth_access_token_ttl: Integer - access token lifetime in seconds (default 3600)
36
+ # oauth_refresh_token_ttl: Integer - refresh token lifetime in seconds (default 2592000 = 30 days)
37
+ # public_url: String - URL of the server for clients, used in OAuth metadata and secret pages
38
+ # (default: from url, mandatory with oauth on a wildcard address)
39
+ # port, bind: deprecated, replaced by url
27
40
  # stateless: Boolean - stateless mode (default false)
28
41
  # allowed_origins: Array<String>
29
42
  # allowed_hosts: Array<String>
@@ -37,10 +50,19 @@ module Aspera
37
50
  # Examples:
38
51
  # ascli mcp server
39
52
  # ascli mcp server @json:{"instructions":"Aspera transfers"}
40
- # ascli mcp server @json:{"transport":"http","port":3000}
53
+ # ascli mcp server @json:{"transport":"http","url":"http://127.0.0.1:3000"}
41
54
  # ascli mcp server @json:{"protocol_version":"2024-11-05","max_line_bytes":1048576}
42
55
  class Mcp < Base
43
56
  application_name 'Model Context Protocol Server'
57
+
58
+ # Instruction for AI agents to always display the equivalent CLI command in their output.
59
+ COMMAND_DISPLAY_INSTRUCTIONS = <<~INST.strip
60
+ COMMAND DISPLAY (MANDATORY)
61
+ Whenever you execute an ascli command or present its results, you MUST ALWAYS explicitly
62
+ show the equivalent command line in your response to the user as bash syntax
63
+ (e.g. `ascli plugin command --option=value`).
64
+ INST
65
+
44
66
  # Default instructions shown to the AI client when none are provided by the user.
45
67
  DEFAULT_INSTRUCTIONS = <<~INST.strip
46
68
  This is the Aspera CLI (ascli) MCP server (IBM Aspera file transfer and management).
@@ -48,6 +70,8 @@ module Aspera
48
70
  Its description gives the syntax and the discovery sequence: follow it before any
49
71
  command whose syntax you have not verified in this session.
50
72
 
73
+ #{COMMAND_DISPLAY_INSTRUCTIONS}
74
+
51
75
  Plugins: aoc (Aspera on Cloud), faspex5 (Faspex 5), node (Node API), server (FASP/SSH
52
76
  server), config (local configuration), console, orchestrator, ats, preview, shares,
53
77
  cos, httpgw, faspio, alee.
@@ -58,6 +82,8 @@ module Aspera
58
82
  If the user provides credentials inline (--url, --username, --password, --private-key),
59
83
  use those exact values verbatim — never substitute a preset or another server.
60
84
  Some plugins support browser login (--auth=web).
85
+ For a secret the user did not give (password, passphrase), pass "@secret:<name>" as value:
86
+ the user enters it on a web page, you never see it. Prefer it to asking in the conversation.
61
87
  On error, report it as-is and stop.
62
88
 
63
89
  TRANSFERS
@@ -68,34 +94,68 @@ module Aspera
68
94
  monitor → ["config", "transfer", "status", "<job_id>"]
69
95
  list → ["config", "transfer", "list"]
70
96
  cleanup → ["config", "transfer", "cleanup"]
97
+
98
+ ORCHESTRATOR WORKFLOW DEVELOPMENT
99
+ Before writing or modifying an Orchestrator workflow, read the documentation for workflow
100
+ development (file format, graph, execution, recipes, authoring checklist):
101
+ ["orchestrator", "workflows", "development", "authoring"]
102
+ For the template attributes (allowed values), inputs and outputs of an action plugin, and the
103
+ template settings they depend on, read its specification (fields: section "plugin-specifications"
104
+ of the authoring guide); without plugin name, the list of plugins:
105
+ ["orchestrator", "plugins", "spec", "<PluginClass>"]
106
+ For the functional description and the setup of a plugin (for example the API client of Faspex),
107
+ read its section of the Plugin Manual (large: read one section):
108
+ ["orchestrator", "workflows", "development", "manual", "<anchor>"]
109
+ The anchor "toc" lists the sections of a document, up to a default level, or the level given after it.
110
+ Before importing a workflow file, check it (graph, templates, inputs of steps) and fix all errors:
111
+ ["orchestrator", "workflows", "check_import", "<file>"]
71
112
  INST
72
113
 
73
114
  # Keys forwarded to MCP::Server constructor (symbolized)
74
- SERVER_KEYS = %i[instructions description].freeze
115
+ SERVER_KEYS = %i[instructions description].freeze
75
116
  # Keys forwarded to MCP::Configuration
76
- CONFIG_KEYS = %i[protocol_version validate_tool_call_arguments].freeze
117
+ CONFIG_KEYS = %i[protocol_version validate_tool_call_arguments].freeze
77
118
  # Keys forwarded to StdioTransport
78
- STDIO_KEYS = %i[max_line_bytes].freeze
119
+ STDIO_KEYS = %i[max_line_bytes].freeze
79
120
  # Keys forwarded to StreamableHTTPTransport
80
- HTTP_KEYS = %i[stateless allowed_origins allowed_hosts session_idle_timeout max_sessions].freeze
121
+ HTTP_KEYS = %i[stateless allowed_origins allowed_hosts session_idle_timeout max_sessions].freeze
122
+ # Keys of the HTTP server, consumed locally
123
+ HTTP_SERVER_KEYS = %i[url cert key chain port bind public_url auth_token auth_token_min_bits oauth oauth_access_token_ttl oauth_refresh_token_ttl].freeze
81
124
  # Keys consumed locally (not forwarded to MCP gem)
82
- TOOL_KEYS = %i[max_text_bytes extra_args].freeze
83
- private_constant :SERVER_KEYS, :CONFIG_KEYS, :STDIO_KEYS, :HTTP_KEYS, :TOOL_KEYS
125
+ TOOL_KEYS = %i[max_text_bytes extra_args].freeze
126
+ # Extended-value handlers disabled in MCP server mode whatever the security level:
127
+ # @stdin: is the JSON-RPC stream (stdio).
128
+ # Other unsafe handlers depend on the security level, see Security.
129
+ # @secret: is entered on a web page instead of the terminal, see Mcp::Secrets.
130
+ DISABLED_EXTENDED_VALUE_HANDLERS = %i[stdin].freeze
131
+ # Security level when not set by option `security`
132
+ DEFAULT_SECURITY_LEVEL = :high
133
+ private_constant :SERVER_KEYS, :CONFIG_KEYS, :STDIO_KEYS, :HTTP_KEYS, :HTTP_SERVER_KEYS, :TOOL_KEYS, :DISABLED_EXTENDED_VALUE_HANDLERS, :DEFAULT_SECURITY_LEVEL
84
134
 
85
135
  command :server, description: 'Start the MCP (Model Context Protocol) server',
86
136
  arguments: [{name: :mcp_options, type: [Hash], mandatory: false, schema: 'opts:components.schemas.McpServerOptions'}]
87
137
 
88
138
  def action_server(mcp_options: nil, **)
89
- require 'aspera/cli/mcp_tool'
139
+ require 'aspera/cli/mcp/tool'
140
+ require 'aspera/cli/extended_value'
90
141
  mcp_options = (mcp_options || {}).transform_keys(&:to_sym)
91
- unknown = mcp_options.keys - SERVER_KEYS - CONFIG_KEYS - STDIO_KEYS - HTTP_KEYS - TOOL_KEYS - %i[transport port bind]
142
+ unknown = mcp_options.keys - SERVER_KEYS - CONFIG_KEYS - STDIO_KEYS - HTTP_KEYS - HTTP_SERVER_KEYS - TOOL_KEYS - %i[transport]
92
143
  Aspera.assert(unknown.empty?, type: Cli::BadArgument) { "Unknown MCP option(s): #{unknown.join(', ')}" }
93
- Cli::McpTool.max_text_bytes = mcp_options.delete(:max_text_bytes)
94
- Cli::McpTool.extra_args = mcp_options.delete(:extra_args)
144
+ Cli::Mcp::Tool.max_text_bytes = mcp_options.delete(:max_text_bytes)
145
+ Cli::Mcp::Tool.extra_args = mcp_options.delete(:extra_args)
95
146
  transport = mcp_options.delete(:transport) || 'stdio'
96
147
  raise Cli::BadArgument, "Unknown transport: #{transport}. Use 'stdio' or 'http'" \
97
148
  unless %w[stdio http].include?(transport.to_s)
98
- Log.log.info { "Starting MCP server (transport=#{transport})..." }
149
+ # Options of the other transport would be ignored
150
+ ignored = mcp_options.keys & (transport.to_s.eql?('stdio') ? HTTP_KEYS + HTTP_SERVER_KEYS : STDIO_KEYS)
151
+ Aspera.assert(ignored.empty?, type: Cli::BadArgument) { "MCP option(s) not used with transport #{transport}: #{ignored.join(', ')}" }
152
+ DISABLED_EXTENDED_VALUE_HANDLERS.each do |handler|
153
+ ExtendedValue.instance.on(handler) { |_| raise Cli::BadArgument, "@#{handler}: extended value is disabled in MCP server mode" }
154
+ end
155
+ ExtendedValue.instance.on(:secret) { |prompt| Cli::Mcp::Secrets.value(prompt) }
156
+ # Applies to commands of the client, and to presets, which the client can modify
157
+ Security.instance.lock('in MCP server mode', default: DEFAULT_SECURITY_LEVEL)
158
+ Log.log.info { "Starting MCP server (transport=#{transport}, security level #{Security.instance.level})..." }
99
159
  start_mcp_server(transport: transport.to_sym, mcp_options: mcp_options)
100
160
  Result::Nothing.new
101
161
  end
@@ -106,17 +166,19 @@ module Aspera
106
166
  # @param mcp_options [Hash] symbolized options
107
167
  # @return [MCP::Server]
108
168
  def build_mcp_server(mcp_options)
109
- tool = Cli::McpTool
169
+ tool = Cli::Mcp::Tool
110
170
  config_opts = mcp_options.slice(*CONFIG_KEYS)
111
171
  server_opts = mcp_options.slice(*SERVER_KEYS)
112
172
  server_opts[:instructions] ||= DEFAULT_INSTRUCTIONS
113
173
  server_opts[:description] ||= "#{Info::GEM_NAME} MCP server (IBM Aspera file transfer and management)"
114
174
  configuration = config_opts.empty? ? nil : MCP::Configuration.new(**config_opts)
115
175
  MCP::Server.new(
116
- name: Info::GEM_NAME,
117
- version: VERSION,
118
- tools: [tool],
119
- configuration: configuration,
176
+ name: Info::GEM_NAME,
177
+ version: VERSION,
178
+ tools: [tool],
179
+ configuration: configuration,
180
+ # The request state of secret requests (@secret:) goes through the client: encrypted and bound to the tool call
181
+ request_state_security: MCP::Server::RequestStateSecurity.new(key: SecureRandom.random_bytes(32), ttl: Cli::WebSecret::TTL),
120
182
  **server_opts
121
183
  )
122
184
  end
@@ -128,6 +190,8 @@ module Aspera
128
190
  server = build_mcp_server(mcp_options)
129
191
  case transport
130
192
  when :stdio
193
+ # Secret pages are served by a local web server, started on first use of @secret:
194
+ Cli::Mcp::Tool.web_secret = Cli::Mcp::Secrets.web_secret
131
195
  stdio_opts = mcp_options.slice(*STDIO_KEYS)
132
196
  MCP::Server::Transports::StdioTransport.new(server, **stdio_opts).open
133
197
  when :http
@@ -135,108 +199,103 @@ module Aspera
135
199
  end
136
200
  end
137
201
 
138
- # Start HTTP transport using WEBrick (already a project dependency).
139
- # Rack 3 removed Rack::Handler - we build a native WEBrick servlet that
140
- # calls the Rack app directly instead of relying on Rack::Handler::WEBrick.
202
+ # Addresses listening on all interfaces: not a host name usable by clients
203
+ WILDCARD_BIND_ADDRESSES = %w[0.0.0.0 ::].freeze
204
+ # Address of the HTTP transport, when not set by option `url`
205
+ DEFAULT_HTTP_URL = 'http://127.0.0.1:3000'
206
+ private_constant :WILDCARD_BIND_ADDRESSES, :DEFAULT_HTTP_URL
207
+
208
+ # @param mcp_options [Hash] symbolized options
209
+ # @return [Hash] Parameters of WebServerSimple.serve: `url`, and `cert`, `key`, `chain` if set
210
+ def http_server_parameters(mcp_options)
211
+ url = mcp_options[:url]
212
+ bind, port = mcp_options.values_at(:bind, :port)
213
+ unless bind.nil? && port.nil?
214
+ Aspera.assert(url.nil?, type: Cli::BadArgument) { 'url cannot be used with bind or port' }
215
+ Log.log.warn { "MCP options bind and port are #{Deprecation.new(last: '4.27.5', message: 'use url')}" }
216
+ uri = URI.parse(DEFAULT_HTTP_URL)
217
+ uri.hostname = bind.to_s unless bind.nil?
218
+ uri.port = port unless port.nil?
219
+ url = uri.to_s
220
+ end
221
+ url ||= DEFAULT_HTTP_URL
222
+ # Paths of the servlet are absolute
223
+ Aspera.assert(['', '/'].include?(URI.parse(url).path), type: Cli::BadArgument) { "url shall have no path (the MCP endpoint is /mcp): #{url}" }
224
+ {url: url, **mcp_options.slice(:cert, :key, :chain)}
225
+ end
226
+
227
+ # @param uri [URI] Address the server listens on
228
+ # @param public_url [String, nil] URL of the server for clients, if provided by user
229
+ # @param oauth_enabled [Boolean] whether OAuth PKCE flow is enabled
230
+ # @return [String] URL of the server for clients, without trailing slash
231
+ def http_base_url(uri, public_url, oauth_enabled)
232
+ return public_url.to_s.chomp('/') unless public_url.nil?
233
+ wildcard = WILDCARD_BIND_ADDRESSES.include?(uri.hostname)
234
+ Aspera.assert(!oauth_enabled || !wildcard, type: Cli::BadArgument) do
235
+ "public_url is required with oauth when listening on #{uri.hostname}: URL used by clients, e.g. #{uri.scheme}://myhost:#{uri.port}"
236
+ end
237
+ URI.parse("#{uri.scheme}://#{wildcard ? 'localhost' : uri.host}:#{uri.port}").to_s
238
+ end
239
+
240
+ # Start HTTP transport, see Mcp::HttpServlet.
241
+ # On an address that is not loopback, `auth_token` is mandatory, also with OAuth:
242
+ # it is the secret asked on the consent page, else anyone could approve its own client.
141
243
  def start_http_transport(server, mcp_options)
142
- require 'webrick'
143
- http_opts = mcp_options.slice(*HTTP_KEYS)
144
- port = mcp_options.fetch(:port, 3000)
145
- bind = mcp_options.fetch(:bind, '127.0.0.1')
146
- app = MCP::Server::Transports::StreamableHTTPTransport.new(server, **http_opts)
147
- rack_servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
148
- define_method(:initialize) do |srv, rack_app, server_info|
149
- @app = rack_app
150
- @server_info = server_info
151
- super(srv)
152
- end
153
- %w[GET POST DELETE].each do |http_method|
154
- define_method(:"do_#{http_method}") do |req, res|
155
- # Serve a discovery endpoint on GET / (used by Bob and other MCP clients
156
- # to display server metadata without initiating a full MCP session).
157
- if http_method == 'GET' && req.path == '/'
158
- body = JSON.generate(@server_info)
159
- res.status = 200
160
- res['Content-Type'] = 'application/json'
161
- res.body = body
162
- next
163
- end
164
- env = rack_env_from_webrick(req)
165
- status, headers, rack_body = @app.call(env)
166
- res.status = status
167
- headers.each { |k, v| res[k] = v }
168
- if rack_body.respond_to?(:call)
169
- # Rack streaming body (SSE): wrap the WEBrick socket in a stream object
170
- # that exposes write/flush/close, then hand off a Proc to WEBrick so it
171
- # calls us back with the raw socket once headers have been flushed.
172
- rack_proc = rack_body
173
- res.chunked = true
174
- res.body = proc do |socket|
175
- stream = Object.new
176
- stream.define_singleton_method(:write) { |data| socket.write(data) }
177
- stream.define_singleton_method(:flush) { socket.flush rescue nil }
178
- stream.define_singleton_method(:close) { socket.close rescue nil }
179
- rack_proc.call(stream)
180
- end
181
- else
182
- buf = +''
183
- rack_body.each { |chunk| buf << chunk }
184
- rack_body.close if rack_body.respond_to?(:close)
185
- res.body = buf
186
- end
187
- end
188
- end
189
- define_method(:rack_env_from_webrick) do |req|
190
- {
191
- 'REQUEST_METHOD' => req.request_method,
192
- 'SCRIPT_NAME' => '',
193
- 'PATH_INFO' => req.path,
194
- 'QUERY_STRING' => req.query_string || '',
195
- 'SERVER_NAME' => req.host,
196
- 'SERVER_PORT' => req.port.to_s,
197
- 'HTTP_VERSION' => req.http_version,
198
- 'rack.input' => StringIO.new(req.body.to_s),
199
- 'rack.errors' => $stderr,
200
- 'rack.url_scheme' => 'http',
201
- 'rack.multithread' => true,
202
- 'rack.multiprocess' => false,
203
- 'rack.run_once' => false
204
- }.tap do |env|
205
- req.header.each do |key, values|
206
- http_key = "HTTP_#{key.upcase.tr('-', '_')}"
207
- case key.downcase
208
- when 'content-type' then env['CONTENT_TYPE'] = values.first
209
- when 'content-length' then env['CONTENT_LENGTH'] = values.first
210
- else env[http_key] = values.join(', ')
211
- end
212
- end
213
- end
244
+ require 'aspera/cli/mcp/http_servlet'
245
+ require 'aspera/web_server_simple'
246
+ parameters = http_server_parameters(mcp_options)
247
+ uri = WebServerSimple.url(parameters)
248
+ auth_token = mcp_options[:auth_token]
249
+ oauth_enabled = mcp_options.fetch(:oauth, false)
250
+ access_token_ttl = mcp_options.fetch(:oauth_access_token_ttl, OAuth::Server::Pkce.default_access_token_ttl)
251
+ refresh_token_ttl = mcp_options.fetch(:oauth_refresh_token_ttl, OAuth::Server::Pkce.default_refresh_token_ttl)
252
+ min_bits = mcp_options.fetch(:auth_token_min_bits, SecretStrength::DEFAULT_MIN_BITS)
253
+ SecretStrength.assert_strong(auth_token, name: 'auth_token', min_bits: min_bits, type: Cli::BadArgument) unless auth_token.nil?
254
+ base_url = http_base_url(uri, mcp_options[:public_url], oauth_enabled)
255
+ what = "auth_token#{' (asked on the consent page)' if oauth_enabled}"
256
+ # Clients reach the server at public_url (e.g. through a reverse proxy), whatever the address it listens on
257
+ unless mcp_options[:public_url].nil?
258
+ public_host = URI.parse(base_url).hostname
259
+ Aspera.assert(!auth_token.nil? || WebServerSimple.loopback?(public_host), type: Cli::BadArgument) do
260
+ "#{what} is required when public_url is not loopback (localhost, 127.0.0.0/8, ::1): #{public_host}"
214
261
  end
215
262
  end
216
- # WEBrick logs Errno::ECONNRESET as ERROR when a client disconnects mid-stream
217
- # (normal for SSE/streaming connections). Suppress those noisy non-fatal errors.
218
- quiet_logger = WEBrick::Log.new($stderr).tap do |log|
219
- log.define_singleton_method(:error) do |msg|
220
- return if msg.to_s.include?('ECONNRESET') || msg.to_s.include?('Broken pipe')
221
- super(msg)
263
+ WebServerSimple.serve(
264
+ parameters,
265
+ Cli::Mcp::HttpServlet,
266
+ protected: !auth_token.nil?,
267
+ what: what,
268
+ type: Cli::BadArgument
269
+ ) do
270
+ # Secret pages are served by this server: the static token, if any, is the access code asked on the page
271
+ web_secret = Cli::Mcp::Secrets.web_secret(base_url: base_url, access_code: auth_token)
272
+ Cli::Mcp::Tool.web_secret = web_secret
273
+ # Build OAuth::Server::Pkce only when OAuth is enabled.
274
+ # Pass MCP-specific branding via title: and description: so the external HTML
275
+ # template is used directly without a custom callback.
276
+ # The static token, if any, is the access code asked on the consent page.
277
+ oauth_server = if oauth_enabled
278
+ Log.log.info { 'OAuth 2.0 PKCE enabled (dynamic registration)' }
279
+ OAuth::Server::Pkce.new(
280
+ base_url: base_url,
281
+ access_token_ttl: access_token_ttl,
282
+ refresh_token_ttl: refresh_token_ttl,
283
+ title: "Authorize #{Info::GEM_NAME} MCP server",
284
+ description: "the <strong>#{Info::GEM_NAME} MCP server</strong>",
285
+ consent_secret: auth_token
286
+ )
222
287
  end
288
+ [{
289
+ app: MCP::Server::Transports::StreamableHTTPTransport.new(server, **mcp_options.slice(*HTTP_KEYS)),
290
+ server_info: {name: server.name, version: server.version, description: server.description},
291
+ web_secret: web_secret,
292
+ auth_token: auth_token,
293
+ oauth_server: oauth_server,
294
+ allowed_hosts: mcp_options[:allowed_hosts],
295
+ # Pages of the server (consent, secret) are opened at base_url, also behind a reverse proxy that changes Host
296
+ allowed_origins: [*mcp_options[:allowed_origins], ServletHelpers.origin(base_url)]
297
+ }]
223
298
  end
224
- webrick = WEBrick::HTTPServer.new(
225
- BindAddress: bind,
226
- Port: port,
227
- Logger: quiet_logger,
228
- AccessLog: []
229
- )
230
- server_info = {
231
- name: server.name,
232
- version: server.version,
233
- description: server.description
234
- }
235
- webrick.mount('/', rack_servlet, app, server_info)
236
- Log.log.info { "MCP HTTP server listening on http://#{bind}:#{port}/" }
237
- trap('INT') { webrick.shutdown }
238
- trap('TERM') { webrick.shutdown }
239
- webrick.start
240
299
  end
241
300
  end
242
301
  end
@@ -10,7 +10,7 @@ require 'aspera/nagios'
10
10
  require 'aspera/hash_ext'
11
11
  require 'aspera/id_generator'
12
12
  require 'aspera/api/node'
13
- require 'aspera/oauth'
13
+ require 'aspera/oauth/client/factory'
14
14
  require 'aspera/rest/list'
15
15
  require 'aspera/assert'
16
16
  require 'base64'
@@ -83,10 +83,12 @@ module Aspera
83
83
  # @param app_url [String] Tested URL
84
84
  # @return [Hash] :preset_value, :test_args
85
85
  def wizard(wizard, app_url)
86
+ username = options.get_option(:username, mandatory: true)
86
87
  return {
88
+ preset_ids: [username],
87
89
  preset_value: {
88
90
  url: app_url,
89
- username: options.get_option(:username, mandatory: true),
91
+ username: username,
90
92
  password: options.get_option(:password, mandatory: true)
91
93
  },
92
94
  test_args: 'info'
@@ -1130,21 +1132,25 @@ module Aspera
1130
1132
  end
1131
1133
 
1132
1134
  def action_emulator(parameters: {}, **)
1135
+ require 'aspera/secret_strength'
1136
+ require 'aspera/web_server_simple'
1133
1137
  require 'aspera/node_emulator'
1134
1138
  # missing parameters take the default of the schema
1135
1139
  defaults = Schema::Registry.instance.reader(Schema::Registry::NODE_EMULATOR_OPTIONS).current['properties'].each_with_object({}) do |(name, property), result|
1136
1140
  result[name.to_sym] = property['default'] if property.key?('default')
1137
1141
  end
1138
1142
  parameters = defaults.merge(parameters.symbolize_keys)
1139
- uri = URI.parse(parameters.delete(:url))
1140
- config = parameters.except(*WebServerSimple::PARAMS)
1143
+ config = WebServerSimple.servlet_parameters(parameters)
1141
1144
  docroot = config.delete(:docroot)
1142
1145
  retention_sec = config.delete(:retention_sec)
1146
+ password_min_bits = config.delete(:password_min_bits)
1143
1147
  Aspera.assert(config[:username].nil? == config[:password].nil?, type: Cli::BadArgument) { 'Parameters username and password must be set together' }
1144
- Log.log.warn('No username and password: emulator accepts requests without authentication') if config[:username].nil?
1145
- server = WebServerSimple.new(uri, **parameters.slice(*WebServerSimple::PARAMS))
1146
- server.mount(uri.path, NodeEmulatorServlet, config, NodeEmulator.new(docroot: docroot, retention_sec: retention_sec))
1147
- server.start
1148
+ SecretStrength.assert_strong(config[:password], name: 'password', min_bits: password_min_bits, type: Cli::BadArgument) unless config[:password].nil?
1149
+ # Files of the docroot can be sent to, or received from, any host by anyone who can reach the server
1150
+ WebServerSimple.serve(parameters, NodeEmulatorServlet, protected: !config[:username].nil?, what: 'username and password', type: Cli::BadArgument) do
1151
+ Log.log.warn('No username and password: emulator accepts requests without authentication') if config[:username].nil?
1152
+ [config, NodeEmulator.new(docroot: docroot, retention_sec: retention_sec)]
1153
+ end
1148
1154
  return Result::Status.new('Emulator terminated')
1149
1155
  end
1150
1156