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.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +281 -21
- data/CONTRIBUTING.md +98 -7
- data/bin/ascli +3 -3
- data/docs/README.md +10757 -0
- data/docs/test-mcp-with-ai.md +202 -0
- data/lib/aspera/agent/base.rb +20 -6
- data/lib/aspera/agent/connect.rb +53 -16
- data/lib/aspera/agent/desktop.rb +52 -17
- data/lib/aspera/agent/direct.rb +26 -24
- data/lib/aspera/agent/httpgw.rb +1 -1
- data/lib/aspera/agent/node.rb +32 -0
- data/lib/aspera/agent/transferd.rb +39 -3
- data/lib/aspera/api/alee.rb +1 -1
- data/lib/aspera/api/aoc.rb +14 -16
- data/lib/aspera/api/cos_node.rb +1 -1
- data/lib/aspera/api/faspex.rb +15 -14
- data/lib/aspera/api/httpgw.rb +4 -4
- data/lib/aspera/api/node.rb +26 -23
- data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
- data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
- data/lib/aspera/ascmd.rb +21 -19
- data/lib/aspera/ascp/installation.rb +37 -38
- data/lib/aspera/ascp/management.rb +2 -3
- data/lib/aspera/cli/ascp_actions.rb +155 -0
- data/lib/aspera/cli/async_transfer_store.rb +81 -0
- data/lib/aspera/cli/bootstrapper.rb +197 -0
- data/lib/aspera/cli/command_registry.rb +145 -0
- data/lib/aspera/cli/command_spec.rb +138 -0
- data/lib/aspera/cli/context.rb +16 -3
- data/lib/aspera/cli/error.rb +16 -0
- data/lib/aspera/cli/extended_value.rb +2 -1
- data/lib/aspera/cli/formatter.rb +41 -27
- data/lib/aspera/cli/gem_checker.rb +65 -0
- data/lib/aspera/cli/hints.rb +2 -2
- data/lib/aspera/cli/http.rb +70 -19
- data/lib/aspera/cli/info.rb +2 -0
- data/lib/aspera/cli/mailer.rb +97 -0
- data/lib/aspera/cli/mcp_tool.rb +198 -0
- data/lib/aspera/cli/options.schema.yaml +364 -7
- data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
- data/lib/aspera/cli/plugins/alee.rb +20 -22
- data/lib/aspera/cli/plugins/aoc.rb +1166 -853
- data/lib/aspera/cli/plugins/ats.rb +200 -161
- data/lib/aspera/cli/plugins/base.rb +457 -100
- data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
- data/lib/aspera/cli/plugins/config.rb +428 -754
- data/lib/aspera/cli/plugins/console.rb +106 -64
- data/lib/aspera/cli/plugins/cos.rb +44 -32
- data/lib/aspera/cli/plugins/factory.rb +4 -4
- data/lib/aspera/cli/plugins/faspex.rb +294 -257
- data/lib/aspera/cli/plugins/faspex5.rb +585 -357
- data/lib/aspera/cli/plugins/faspio.rb +49 -51
- data/lib/aspera/cli/plugins/httpgw.rb +18 -25
- data/lib/aspera/cli/plugins/mcp.rb +279 -0
- data/lib/aspera/cli/plugins/node.rb +998 -794
- data/lib/aspera/cli/plugins/oauth.rb +7 -10
- data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
- data/lib/aspera/cli/plugins/preview.rb +180 -139
- data/lib/aspera/cli/plugins/server.rb +124 -79
- data/lib/aspera/cli/plugins/shares.rb +301 -107
- data/lib/aspera/cli/preset_actions.rb +139 -0
- data/lib/aspera/cli/preset_manager.rb +13 -12
- data/lib/aspera/cli/result.rb +61 -11
- data/lib/aspera/cli/runner.rb +275 -141
- data/lib/aspera/cli/secret_finder.rb +40 -0
- data/lib/aspera/cli/special_values.rb +1 -0
- data/lib/aspera/cli/sync_actions.rb +83 -59
- data/lib/aspera/cli/terminal_formatter.rb +1 -1
- data/lib/aspera/cli/transfer_actions.rb +83 -0
- data/lib/aspera/cli/transfer_agent.rb +116 -51
- data/lib/aspera/cli/transfer_progress.rb +3 -3
- data/lib/aspera/cli/vault_manager.rb +57 -0
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/wizard.rb +21 -20
- data/lib/aspera/command_line_builder.rb +22 -22
- data/lib/aspera/data_repository.rb +2 -1
- data/lib/aspera/dot_container.rb +4 -4
- data/lib/aspera/environment.rb +18 -11
- data/lib/aspera/exec_spec.rb +13 -0
- data/lib/aspera/faspex_gw.rb +1 -1
- data/lib/aspera/faspex_postproc.rb +4 -3
- data/lib/aspera/graphql.rb +35 -0
- data/lib/aspera/hash_ext.rb +6 -0
- data/lib/aspera/json_rpc/client.rb +62 -0
- data/lib/aspera/json_rpc/version.rb +7 -0
- data/lib/aspera/keychain/encrypted_hash.rb +1 -1
- data/lib/aspera/keychain/factory.rb +3 -3
- data/lib/aspera/keychain/macos_security.rb +3 -3
- data/lib/aspera/link_header.rb +82 -0
- data/lib/aspera/log.rb +17 -3
- data/lib/aspera/markdown.rb +85 -2
- data/lib/aspera/node_simulator.rb +3 -1
- data/lib/aspera/oauth/base.rb +5 -5
- data/lib/aspera/oauth/boot.rb +2 -2
- data/lib/aspera/oauth/factory.rb +7 -5
- data/lib/aspera/oauth/jwt.rb +4 -4
- data/lib/aspera/oauth/web.rb +2 -2
- data/lib/aspera/persistency_action_once.rb +8 -8
- data/lib/aspera/persistency_folder.rb +10 -3
- data/lib/aspera/preview/file_types.rb +3 -3
- data/lib/aspera/preview/generator.rb +3 -3
- data/lib/aspera/preview/utils.rb +1 -1
- data/lib/aspera/products/connect.rb +1 -1
- data/lib/aspera/products/other.rb +1 -1
- data/lib/aspera/products/transferd.rb +1 -1
- data/lib/aspera/proxy_auto_config.rb +7 -6
- data/lib/aspera/rest.rb +10 -36
- data/lib/aspera/rest_error_analyzer.rb +3 -3
- data/lib/aspera/rest_errors_aspera.rb +0 -10
- data/lib/aspera/rest_list.rb +3 -3
- data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
- data/lib/aspera/schema/documentation.rb +57 -26
- data/lib/aspera/schema/reader.rb +59 -10
- data/lib/aspera/schema/registry.rb +39 -6
- data/lib/aspera/secret_hider.rb +7 -2
- data/lib/aspera/ssl.rb +4 -4
- data/lib/aspera/sync/conf.schema.yaml +2 -2
- data/lib/aspera/sync/operations.rb +10 -13
- data/lib/aspera/temp_file_manager.rb +1 -1
- data/lib/aspera/timer_limiter.rb +1 -1
- data/lib/aspera/transfer/faux_file.rb +24 -11
- data/lib/aspera/transfer/parameters.rb +23 -21
- data/lib/aspera/transfer/result.rb +74 -0
- data/lib/aspera/transfer/resumer.rb +6 -6
- data/lib/aspera/transfer/spec.rb +18 -1
- data/lib/aspera/transfer/spec.schema.yaml +12 -3
- data/lib/aspera/uri_reader.rb +2 -2
- data/lib/aspera/web_auth.rb +2 -2
- data/lib/aspera/web_server_simple.rb +8 -5
- data/lib/aspera/yaml.rb +2 -1
- data.tar.gz.sig +0 -0
- metadata +25 -3
- metadata.gz.sig +0 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|