aspera-cli 4.26.2 → 4.27.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 (136) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +281 -21
  4. data/CONTRIBUTING.md +98 -7
  5. data/bin/ascli +3 -3
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +26 -24
  12. data/lib/aspera/agent/httpgw.rb +1 -1
  13. data/lib/aspera/agent/node.rb +32 -0
  14. data/lib/aspera/agent/transferd.rb +39 -3
  15. data/lib/aspera/api/alee.rb +1 -1
  16. data/lib/aspera/api/aoc.rb +14 -16
  17. data/lib/aspera/api/cos_node.rb +1 -1
  18. data/lib/aspera/api/faspex.rb +15 -14
  19. data/lib/aspera/api/httpgw.rb +4 -4
  20. data/lib/aspera/api/node.rb +26 -23
  21. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  22. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  23. data/lib/aspera/ascmd.rb +21 -19
  24. data/lib/aspera/ascp/installation.rb +37 -38
  25. data/lib/aspera/ascp/management.rb +2 -3
  26. data/lib/aspera/cli/ascp_actions.rb +155 -0
  27. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  28. data/lib/aspera/cli/bootstrapper.rb +197 -0
  29. data/lib/aspera/cli/command_registry.rb +145 -0
  30. data/lib/aspera/cli/command_spec.rb +138 -0
  31. data/lib/aspera/cli/context.rb +16 -3
  32. data/lib/aspera/cli/error.rb +16 -0
  33. data/lib/aspera/cli/extended_value.rb +2 -1
  34. data/lib/aspera/cli/formatter.rb +41 -27
  35. data/lib/aspera/cli/gem_checker.rb +65 -0
  36. data/lib/aspera/cli/hints.rb +2 -2
  37. data/lib/aspera/cli/http.rb +70 -19
  38. data/lib/aspera/cli/info.rb +2 -0
  39. data/lib/aspera/cli/mailer.rb +97 -0
  40. data/lib/aspera/cli/mcp_tool.rb +198 -0
  41. data/lib/aspera/cli/options.schema.yaml +364 -7
  42. data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
  43. data/lib/aspera/cli/plugins/alee.rb +20 -22
  44. data/lib/aspera/cli/plugins/aoc.rb +1166 -853
  45. data/lib/aspera/cli/plugins/ats.rb +200 -161
  46. data/lib/aspera/cli/plugins/base.rb +457 -100
  47. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  48. data/lib/aspera/cli/plugins/config.rb +428 -754
  49. data/lib/aspera/cli/plugins/console.rb +106 -64
  50. data/lib/aspera/cli/plugins/cos.rb +44 -32
  51. data/lib/aspera/cli/plugins/factory.rb +4 -4
  52. data/lib/aspera/cli/plugins/faspex.rb +294 -257
  53. data/lib/aspera/cli/plugins/faspex5.rb +585 -357
  54. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  55. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  56. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  57. data/lib/aspera/cli/plugins/node.rb +998 -794
  58. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  59. data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
  60. data/lib/aspera/cli/plugins/preview.rb +180 -139
  61. data/lib/aspera/cli/plugins/server.rb +124 -79
  62. data/lib/aspera/cli/plugins/shares.rb +301 -107
  63. data/lib/aspera/cli/preset_actions.rb +139 -0
  64. data/lib/aspera/cli/preset_manager.rb +13 -12
  65. data/lib/aspera/cli/result.rb +61 -11
  66. data/lib/aspera/cli/runner.rb +275 -141
  67. data/lib/aspera/cli/secret_finder.rb +40 -0
  68. data/lib/aspera/cli/special_values.rb +1 -0
  69. data/lib/aspera/cli/sync_actions.rb +83 -59
  70. data/lib/aspera/cli/terminal_formatter.rb +1 -1
  71. data/lib/aspera/cli/transfer_actions.rb +83 -0
  72. data/lib/aspera/cli/transfer_agent.rb +116 -51
  73. data/lib/aspera/cli/transfer_progress.rb +3 -3
  74. data/lib/aspera/cli/vault_manager.rb +57 -0
  75. data/lib/aspera/cli/version.rb +1 -1
  76. data/lib/aspera/cli/wizard.rb +21 -20
  77. data/lib/aspera/command_line_builder.rb +22 -22
  78. data/lib/aspera/data_repository.rb +2 -1
  79. data/lib/aspera/dot_container.rb +4 -4
  80. data/lib/aspera/environment.rb +18 -11
  81. data/lib/aspera/exec_spec.rb +13 -0
  82. data/lib/aspera/faspex_gw.rb +1 -1
  83. data/lib/aspera/faspex_postproc.rb +4 -3
  84. data/lib/aspera/graphql.rb +35 -0
  85. data/lib/aspera/hash_ext.rb +6 -0
  86. data/lib/aspera/json_rpc/client.rb +62 -0
  87. data/lib/aspera/json_rpc/version.rb +7 -0
  88. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  89. data/lib/aspera/keychain/factory.rb +3 -3
  90. data/lib/aspera/keychain/macos_security.rb +3 -3
  91. data/lib/aspera/link_header.rb +82 -0
  92. data/lib/aspera/log.rb +17 -3
  93. data/lib/aspera/markdown.rb +85 -2
  94. data/lib/aspera/node_simulator.rb +3 -1
  95. data/lib/aspera/oauth/base.rb +5 -5
  96. data/lib/aspera/oauth/boot.rb +2 -2
  97. data/lib/aspera/oauth/factory.rb +7 -5
  98. data/lib/aspera/oauth/jwt.rb +4 -4
  99. data/lib/aspera/oauth/web.rb +2 -2
  100. data/lib/aspera/persistency_action_once.rb +8 -8
  101. data/lib/aspera/persistency_folder.rb +10 -3
  102. data/lib/aspera/preview/file_types.rb +3 -3
  103. data/lib/aspera/preview/generator.rb +3 -3
  104. data/lib/aspera/preview/utils.rb +1 -1
  105. data/lib/aspera/products/connect.rb +1 -1
  106. data/lib/aspera/products/other.rb +1 -1
  107. data/lib/aspera/products/transferd.rb +1 -1
  108. data/lib/aspera/proxy_auto_config.rb +7 -6
  109. data/lib/aspera/rest.rb +10 -36
  110. data/lib/aspera/rest_error_analyzer.rb +3 -3
  111. data/lib/aspera/rest_errors_aspera.rb +0 -10
  112. data/lib/aspera/rest_list.rb +3 -3
  113. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  114. data/lib/aspera/schema/documentation.rb +57 -26
  115. data/lib/aspera/schema/reader.rb +59 -10
  116. data/lib/aspera/schema/registry.rb +39 -6
  117. data/lib/aspera/secret_hider.rb +7 -2
  118. data/lib/aspera/ssl.rb +4 -4
  119. data/lib/aspera/sync/conf.schema.yaml +2 -2
  120. data/lib/aspera/sync/operations.rb +10 -13
  121. data/lib/aspera/temp_file_manager.rb +1 -1
  122. data/lib/aspera/timer_limiter.rb +1 -1
  123. data/lib/aspera/transfer/faux_file.rb +24 -11
  124. data/lib/aspera/transfer/parameters.rb +23 -21
  125. data/lib/aspera/transfer/result.rb +74 -0
  126. data/lib/aspera/transfer/resumer.rb +6 -6
  127. data/lib/aspera/transfer/spec.rb +18 -1
  128. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  129. data/lib/aspera/uri_reader.rb +2 -2
  130. data/lib/aspera/web_auth.rb +2 -2
  131. data/lib/aspera/web_server_simple.rb +8 -5
  132. data/lib/aspera/yaml.rb +2 -1
  133. data.tar.gz.sig +0 -0
  134. metadata +25 -3
  135. metadata.gz.sig +0 -0
  136. data/lib/aspera/json_rpc.rb +0 -52
@@ -8,11 +8,9 @@ module Aspera
8
8
  module Cli
9
9
  module Plugins
10
10
  class Faspio < BasicAuth
11
- class << self
12
- def application_name
13
- 'faspio Gateway'
14
- end
11
+ application_name 'faspio Gateway'
15
12
 
13
+ class << self
16
14
  # @return [Hash,NilClass]
17
15
  def detect(base_url)
18
16
  api = Rest.new(base_url: base_url)
@@ -39,62 +37,62 @@ module Aspera
39
37
  }
40
38
  end
41
39
 
42
- ACTIONS = %i[health bridges].freeze
40
+ command :health, description: 'Check health of faspio Gateway'
41
+ entity_command :bridges, api: :build_api, entity: 'bridges'
42
+
43
+ option :auth, description: 'OAuth type of authentication', allowed: %i[jwt basic]
44
+ option :client_id, description: 'OAuth client identifier'
45
+ option :private_key, description: 'OAuth JWT RSA private key PEM value (prefix file path with @file:)'
46
+ option :passphrase, description: 'OAuth JWT RSA private key passphrase'
43
47
 
44
48
  def initialize(**_)
45
49
  super
46
- options.declare(:auth, 'OAuth type of authentication', allowed: %i[jwt basic])
47
- options.declare(:client_id, 'OAuth client identifier')
48
- options.declare(:private_key, 'OAuth JWT RSA private key PEM value (prefix file path with @file:)')
49
- options.declare(:passphrase, 'OAuth JWT RSA private key passphrase')
50
50
  options.parse_options!
51
51
  end
52
52
 
53
- def execute_action
53
+ # Build the REST API object based on the configured auth type.
54
+ # @return [Rest]
55
+ def build_api
54
56
  base_url = options.get_option(:url, mandatory: true)
55
- api =
56
- case options.get_option(:auth, mandatory: true)
57
- when :basic
58
- basic_auth_api
59
- when :jwt
60
- app_client_id = options.get_option(:client_id, mandatory: true)
61
- Rest.new(
62
- base_url: base_url,
63
- auth: {
64
- type: :oauth2,
65
- grant_method: :jwt,
66
- base_url: "#{base_url}/auth",
67
- params: {
68
- client_id: app_client_id
69
- },
70
- use_query: true,
71
- payload: {
72
- iss: app_client_id, # issuer
73
- sub: app_client_id # subject
74
- },
75
- private_key_obj: OpenSSL::PKey::RSA.new(options.get_option(:private_key, mandatory: true), options.get_option(:passphrase)),
76
- headers: {typ: 'JWT'}
77
- }
78
- )
79
- end
80
- command = options.get_next_command(ACTIONS)
81
- case command
82
- when :health
83
- nagios = Nagios.new
84
- begin
85
- result = api.read('ping')
86
- if result.is_a?(Hash) && result.empty?
87
- nagios.add_ok('api', 'answered ok')
88
- else
89
- nagios.add_critical('api', 'not expected answer')
90
- end
91
- rescue StandardError => e
92
- nagios.add_critical('api', e.to_s)
57
+ case options.get_option(:auth, mandatory: true)
58
+ when :basic
59
+ basic_auth_api
60
+ when :jwt
61
+ app_client_id = options.get_option(:client_id, mandatory: true)
62
+ Rest.new(
63
+ base_url: base_url,
64
+ auth: {
65
+ type: :oauth2,
66
+ grant_method: :jwt,
67
+ base_url: "#{base_url}/auth",
68
+ params: {
69
+ client_id: app_client_id
70
+ },
71
+ use_query: true,
72
+ payload: {
73
+ iss: app_client_id, # issuer
74
+ sub: app_client_id # subject
75
+ },
76
+ private_key_obj: OpenSSL::PKey::RSA.new(options.get_option(:private_key, mandatory: true), options.get_option(:passphrase)),
77
+ headers: {typ: 'JWT'}
78
+ }
79
+ )
80
+ end
81
+ end
82
+
83
+ def action_health
84
+ nagios = Nagios.new
85
+ begin
86
+ result = build_api.read('ping')
87
+ if result.is_a?(Hash) && result.empty?
88
+ nagios.add_ok('api', 'answered ok')
89
+ else
90
+ nagios.add_critical('api', 'not expected answer')
93
91
  end
94
- Result::ObjectList.new(nagios.status_list)
95
- when :bridges
96
- return entity_execute(api: api, entity: 'bridges')
92
+ rescue StandardError => e
93
+ nagios.add_critical('api', e.to_s)
97
94
  end
95
+ Result::ObjectList.new(nagios.status_list)
98
96
  end
99
97
  end
100
98
  end
@@ -9,11 +9,9 @@ module Aspera
9
9
  module Cli
10
10
  module Plugins
11
11
  class Httpgw < Base
12
- class << self
13
- def application_name
14
- 'HTTP Gateway'
15
- end
12
+ application_name 'HTTP Gateway'
16
13
 
14
+ class << self
17
15
  # @return [Hash,NilClass]
18
16
  def detect(base_url)
19
17
  api = Api::Httpgw.new(url: base_url)
@@ -37,32 +35,27 @@ module Aspera
37
35
  test_args: 'info'
38
36
  }
39
37
  end
40
- ACTIONS = %i[health info].freeze
38
+
39
+ command(:health, description: 'Check health of HTTP Gateway', action: lambda do
40
+ nagios = Nagios.new
41
+ begin
42
+ Api::Httpgw.new(url: options.get_option(:url, mandatory: true))
43
+ nagios.add_ok('api', 'answered ok')
44
+ rescue StandardError => e
45
+ nagios.add_critical('api', e.to_s)
46
+ end
47
+ Result::ObjectList.new(nagios.status_list)
48
+ end)
49
+
50
+ command :info, description: 'Show HTTP Gateway information',
51
+ action: ->{Result::SingleObject.new(Api::Httpgw.new(url: options.get_option(:url, mandatory: true)).info)}
52
+
53
+ option :url, description: 'URL of application, e.g. https://app.example.com/aspera/app'
41
54
 
42
55
  def initialize(**_)
43
56
  super
44
- options.declare(:url, 'URL of application, e.g. https://app.example.com/aspera/app')
45
57
  options.parse_options!
46
58
  end
47
-
48
- def execute_action
49
- base_url = options.get_option(:url, mandatory: true)
50
- command = options.get_next_command(ACTIONS)
51
- case command
52
- when :health
53
- nagios = Nagios.new
54
- begin
55
- Api::Httpgw.new(url: base_url)
56
- nagios.add_ok('api', 'answered ok')
57
- rescue StandardError => e
58
- nagios.add_critical('api', e.to_s)
59
- end
60
- Result::ObjectList.new(nagios.status_list)
61
- when :info
62
- api_v1 = Api::Httpgw.new(url: base_url)
63
- return Result::SingleObject.new(api_v1.info)
64
- end
65
- end
66
59
  end
67
60
  end
68
61
  end
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cspell:ignore ascli jsonrpc
4
+
5
+ require 'aspera/cli/plugins/base'
6
+ require 'aspera/cli/info'
7
+ require 'aspera/cli/version'
8
+ require 'json'
9
+
10
+ module Aspera
11
+ module Cli
12
+ module Plugins
13
+ # Plugin to start the MCP (Model Context Protocol) server.
14
+ # The `server` action accepts an optional Hash argument (extended value)
15
+ # to configure the MCP server and transport.
16
+ #
17
+ # Supported keys in the options Hash:
18
+ # transport: "stdio" (default) or "http"
19
+ # extra_args: Array<String> - flags prepended to every ascli call
20
+ # (default: ["--interactive=no", "--transfer.asynchronous=true"])
21
+ # max_text_bytes: Integer - max bytes of JSON text content for list results (default 100_000)
22
+ # # stdio transport:
23
+ # max_line_bytes: Integer - max JSON frame size (default 4 MiB)
24
+ # # http transport:
25
+ # port: Integer - TCP port (default 3000)
26
+ # bind: String - bind address (default "127.0.0.1")
27
+ # stateless: Boolean - stateless mode (default false)
28
+ # allowed_origins: Array<String>
29
+ # allowed_hosts: Array<String>
30
+ # session_idle_timeout: Integer (seconds)
31
+ # max_sessions: Integer
32
+ # # MCP::Server options:
33
+ # instructions: String - hint shown to the AI client
34
+ # protocol_version: String - e.g. "2024-11-05"
35
+ # validate_tool_call_arguments: Boolean (default true)
36
+ #
37
+ # Examples:
38
+ # ascli mcp server
39
+ # ascli mcp server @json:{"instructions":"Aspera transfers"}
40
+ # ascli mcp server @json:{"transport":"http","port":3000}
41
+ # ascli mcp server @json:{"protocol_version":"2024-11-05","max_line_bytes":1048576}
42
+ class Mcp < Base
43
+ application_name 'Model Context Protocol Server'
44
+ # Default instructions shown to the AI client when none are provided by the user.
45
+ DEFAULT_INSTRUCTIONS = <<~INST.strip
46
+ This is the Aspera CLI (ascli) MCP server (IBM Aspera file transfer and management).
47
+ It exposes a single tool, execute_ascli_command, which runs any ascli command in-process.
48
+ Refer to the tool description for full syntax, discovery steps, and examples.
49
+
50
+ Key plugins: aoc (Aspera on Cloud), faspex5 (Faspex 5), node (Node API),
51
+ server (FASP/SSH server), config (local configuration), console, orchestrator,
52
+ ats (Aspera Transfer Service), preview, shares, cos, httpgw, faspio, alee.
53
+
54
+ Recommended workflow for any task:
55
+ 1. Call ["config", "commands"] to map all 800+ commands to their syntax.
56
+ Never guess subcommand names from training data — always verify with this call.
57
+ 2. For any command whose syntax shows a <data> argument, call it with "help"
58
+ instead of the real value to see the full field schema before constructing
59
+ the @json:{...} payload. This is mandatory — never infer fields from errors.
60
+ 2b. For any list command, add --query=help to discover available filter parameters.
61
+ Example: ["aoc", "admin", "user", "list", "--query=help"]
62
+ 3. Call ["config", "options", "<plugin>"] to list every --flag accepted by a
63
+ plugin with its allowed values (e.g. --auth=basic|oauth2|..., --url, --preset).
64
+ 4. When credentials are already saved, use --preset=name instead of inline
65
+ credentials. Call ["config", "preset", "list"] to see saved presets.
66
+
67
+ AUTOMATIC FLAGS
68
+ The server automatically injects --interactive=no and --transfer.asynchronous=true
69
+ before every command. Do NOT add them yourself — they are already applied.
70
+ If credentials are missing, the command returns an error; report it and stop.
71
+ The transfer agent defaults to "direct" (in-process ascp). To use the IBM Aspera
72
+ Desktop Client for all transfers, the server can be started with extra_args including
73
+ --transfer.agent=desktop:
74
+ ascli mcp server @json:{"extra_args":["--interactive=no","--transfer.asynchronous=true","--transfer.agent=desktop"]}
75
+
76
+ CREDENTIAL INTEGRITY
77
+ Use saved presets by default — do not specify server address or credentials if a
78
+ default preset is already configured, unless the user asks otherwise.
79
+ Call ["config", "preset", "overview"] to check available presets.
80
+ Some plugins support web-based authentication: the user logs in via a browser
81
+ (use --auth=web or follow the wizard).
82
+ If the user explicitly provides credentials inline (--url, --username, --password,
83
+ --private-key), use those exact values verbatim — never silently substitute a
84
+ preset or a different server. Report errors as-is and stop.
85
+
86
+ TRANSFER OPTIONS
87
+ Any flags explicitly requested by the user (--transfer.agent=<agent>, --to-folder,
88
+ etc.) must be passed verbatim. Never omit or replace them.
89
+ The agent is selected with "--transfer.agent=<agent>" (e.g. "--transfer.agent=desktop",
90
+ "--transfer.agent=direct", "--transfer.agent=node").
91
+ For a graphical transfer (IBM Aspera Desktop Client), use --transfer.agent=desktop.
92
+ Because --transfer.asynchronous=true is injected automatically, transfer commands
93
+ return a job_id immediately — never retry a command that already returned one.
94
+ Async transfer lifecycle:
95
+ submit → e.g. ["server", "download", "/file", "--to-folder=/tmp"] → returns job_id
96
+ monitor → ["config", "transfer", "status", "<job_id>"]
97
+ list → ["config", "transfer", "list"]
98
+ cleanup → ["config", "transfer", "cleanup"]
99
+
100
+ FILE LIST FOR TRANSFERS
101
+ For all transfers (upload, download, package send, …), append source file paths
102
+ at the end of the args array — no --sources flag needed.
103
+ ["server", "upload", "--to-folder=/dst", "/local/a.txt", "/local/b.txt"]
104
+ ["aoc", "packages", "send", "@:", "name=pkg", "recipients.0=u@example.com", "END",
105
+ "/local/a.txt", "/local/b.txt"]
106
+ INST
107
+
108
+ # Keys forwarded to MCP::Server constructor (symbolized)
109
+ SERVER_KEYS = %i[instructions description].freeze
110
+ # Keys forwarded to MCP::Configuration
111
+ CONFIG_KEYS = %i[protocol_version validate_tool_call_arguments].freeze
112
+ # Keys forwarded to StdioTransport
113
+ STDIO_KEYS = %i[max_line_bytes].freeze
114
+ # Keys forwarded to StreamableHTTPTransport
115
+ HTTP_KEYS = %i[stateless allowed_origins allowed_hosts session_idle_timeout max_sessions].freeze
116
+ # Keys consumed locally (not forwarded to MCP gem)
117
+ TOOL_KEYS = %i[max_text_bytes extra_args].freeze
118
+ private_constant :SERVER_KEYS, :CONFIG_KEYS, :STDIO_KEYS, :HTTP_KEYS, :TOOL_KEYS
119
+
120
+ command :server, description: 'Start the MCP (Model Context Protocol) server',
121
+ arguments: [{name: :mcp_options, type: [Hash], mandatory: false}]
122
+
123
+ def action_server(mcp_options: nil, **)
124
+ require 'aspera/cli/mcp_tool'
125
+ mcp_options = (mcp_options || {}).transform_keys(&:to_sym)
126
+ unknown = mcp_options.keys - SERVER_KEYS - CONFIG_KEYS - STDIO_KEYS - HTTP_KEYS - TOOL_KEYS - %i[transport port bind]
127
+ Aspera.assert(unknown.empty?, type: Cli::BadArgument){"Unknown MCP option(s): #{unknown.join(', ')}"}
128
+ Cli::McpTool.max_text_bytes = mcp_options.delete(:max_text_bytes)
129
+ Cli::McpTool.extra_args = mcp_options.delete(:extra_args)
130
+ transport = mcp_options.delete(:transport) || 'stdio'
131
+ raise Cli::BadArgument, "Unknown transport: #{transport}. Use 'stdio' or 'http'" \
132
+ unless %w[stdio http].include?(transport.to_s)
133
+ Log.log.info{"Starting MCP server (transport=#{transport})..."}
134
+ start_mcp_server(transport: transport.to_sym, mcp_options: mcp_options)
135
+ Result::Nothing.new
136
+ end
137
+
138
+ private
139
+
140
+ # Build the MCP::Server from the options hash.
141
+ # @param mcp_options [Hash] symbolized options
142
+ # @return [MCP::Server]
143
+ def build_mcp_server(mcp_options)
144
+ tool = Cli::McpTool
145
+ config_opts = mcp_options.slice(*CONFIG_KEYS)
146
+ server_opts = mcp_options.slice(*SERVER_KEYS)
147
+ server_opts[:instructions] ||= DEFAULT_INSTRUCTIONS
148
+ server_opts[:description] ||= "#{Info::GEM_NAME} MCP server (IBM Aspera file transfer and management)"
149
+ configuration = config_opts.empty? ? nil : MCP::Configuration.new(**config_opts)
150
+ MCP::Server.new(
151
+ name: Info::GEM_NAME,
152
+ version: VERSION,
153
+ tools: [tool],
154
+ configuration: configuration,
155
+ **server_opts
156
+ )
157
+ end
158
+
159
+ # Build and start the MCP server.
160
+ # @param transport [Symbol] :stdio or :http
161
+ # @param mcp_options [Hash] symbolized options
162
+ def start_mcp_server(transport:, mcp_options:)
163
+ server = build_mcp_server(mcp_options)
164
+ case transport
165
+ when :stdio
166
+ stdio_opts = mcp_options.slice(*STDIO_KEYS)
167
+ MCP::Server::Transports::StdioTransport.new(server, **stdio_opts).open
168
+ when :http
169
+ start_http_transport(server, mcp_options)
170
+ end
171
+ end
172
+
173
+ # Start HTTP transport using WEBrick (already a project dependency).
174
+ # Rack 3 removed Rack::Handler - we build a native WEBrick servlet that
175
+ # calls the Rack app directly instead of relying on Rack::Handler::WEBrick.
176
+ def start_http_transport(server, mcp_options)
177
+ require 'webrick'
178
+ http_opts = mcp_options.slice(*HTTP_KEYS)
179
+ port = mcp_options.fetch(:port, 3000)
180
+ bind = mcp_options.fetch(:bind, '127.0.0.1')
181
+ app = MCP::Server::Transports::StreamableHTTPTransport.new(server, **http_opts)
182
+ rack_servlet = Class.new(WEBrick::HTTPServlet::AbstractServlet) do
183
+ define_method(:initialize) do |srv, rack_app, server_info|
184
+ @app = rack_app
185
+ @server_info = server_info
186
+ super(srv)
187
+ end
188
+ %w[GET POST DELETE].each do |http_method|
189
+ define_method(:"do_#{http_method}") do |req, res|
190
+ # Serve a discovery endpoint on GET / (used by Bob and other MCP clients
191
+ # to display server metadata without initiating a full MCP session).
192
+ if http_method == 'GET' && req.path == '/'
193
+ body = JSON.generate(@server_info)
194
+ res.status = 200
195
+ res['Content-Type'] = 'application/json'
196
+ res.body = body
197
+ next
198
+ end
199
+ env = rack_env_from_webrick(req)
200
+ status, headers, rack_body = @app.call(env)
201
+ res.status = status
202
+ headers.each{ |k, v| res[k] = v}
203
+ if rack_body.respond_to?(:call)
204
+ # Rack streaming body (SSE): wrap the WEBrick socket in a stream object
205
+ # that exposes write/flush/close, then hand off a Proc to WEBrick so it
206
+ # calls us back with the raw socket once headers have been flushed.
207
+ rack_proc = rack_body
208
+ res.chunked = true
209
+ res.body = proc do |socket|
210
+ stream = Object.new
211
+ stream.define_singleton_method(:write){ |data| socket.write(data)}
212
+ stream.define_singleton_method(:flush){socket.flush rescue nil}
213
+ stream.define_singleton_method(:close){socket.close rescue nil}
214
+ rack_proc.call(stream)
215
+ end
216
+ else
217
+ buf = +''
218
+ rack_body.each{ |chunk| buf << chunk}
219
+ rack_body.close if rack_body.respond_to?(:close)
220
+ res.body = buf
221
+ end
222
+ end
223
+ end
224
+ define_method(:rack_env_from_webrick) do |req|
225
+ {
226
+ 'REQUEST_METHOD' => req.request_method,
227
+ 'SCRIPT_NAME' => '',
228
+ 'PATH_INFO' => req.path,
229
+ 'QUERY_STRING' => req.query_string || '',
230
+ 'SERVER_NAME' => req.host,
231
+ 'SERVER_PORT' => req.port.to_s,
232
+ 'HTTP_VERSION' => req.http_version,
233
+ 'rack.input' => StringIO.new(req.body.to_s),
234
+ 'rack.errors' => $stderr,
235
+ 'rack.url_scheme' => 'http',
236
+ 'rack.multithread' => true,
237
+ 'rack.multiprocess' => false,
238
+ 'rack.run_once' => false
239
+ }.tap do |env|
240
+ req.header.each do |key, values|
241
+ http_key = "HTTP_#{key.upcase.tr('-', '_')}"
242
+ case key.downcase
243
+ when 'content-type' then env['CONTENT_TYPE'] = values.first
244
+ when 'content-length' then env['CONTENT_LENGTH'] = values.first
245
+ else env[http_key] = values.join(', ')
246
+ end
247
+ end
248
+ end
249
+ end
250
+ end
251
+ # WEBrick logs Errno::ECONNRESET as ERROR when a client disconnects mid-stream
252
+ # (normal for SSE/streaming connections). Suppress those noisy non-fatal errors.
253
+ quiet_logger = WEBrick::Log.new($stderr).tap do |log|
254
+ log.define_singleton_method(:error) do |msg|
255
+ return if msg.to_s.include?('ECONNRESET') || msg.to_s.include?('Broken pipe')
256
+ super(msg)
257
+ end
258
+ end
259
+ webrick = WEBrick::HTTPServer.new(
260
+ BindAddress: bind,
261
+ Port: port,
262
+ Logger: quiet_logger,
263
+ AccessLog: []
264
+ )
265
+ server_info = {
266
+ name: server.name,
267
+ version: server.version,
268
+ description: server.description
269
+ }
270
+ webrick.mount('/', rack_servlet, app, server_info)
271
+ Log.log.info{"MCP HTTP server listening on http://#{bind}:#{port}/"}
272
+ trap('INT'){webrick.shutdown}
273
+ trap('TERM'){webrick.shutdown}
274
+ webrick.start
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end