aspera-cli 4.27.5 → 4.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +160 -4
- data/CONTRIBUTING.md +4 -2
- data/README.md +1 -1
- data/bin/asession +1 -1
- data/docs/README.md +1086 -331
- data/docs/test-mcp-with-ai.md +5 -3
- data/lib/aspera/agent/direct.rb +15 -4
- data/lib/aspera/agent/factory.rb +2 -1
- data/lib/aspera/agent/node.rb +1 -1
- data/lib/aspera/api/cos_node.rb +1 -1
- data/lib/aspera/api/faspex.rb +1 -1
- data/lib/aspera/api/node.rb +6 -2
- data/lib/aspera/ascp/installation.rb +47 -11
- data/lib/aspera/ascp/management.rb +89 -43
- data/lib/aspera/cli/ascp_actions.rb +5 -19
- data/lib/aspera/cli/bootstrapper.rb +1 -2
- data/lib/aspera/cli/command_registry.rb +43 -3
- data/lib/aspera/cli/command_spec.rb +3 -0
- data/lib/aspera/cli/completion/ascli.bash +39 -5
- data/lib/aspera/cli/completion/ascli.fish +16 -8
- data/lib/aspera/cli/completion/ascli.ps1 +41 -0
- data/lib/aspera/cli/completion/ascli.zsh +20 -4
- data/lib/aspera/cli/completion_actions.rb +360 -0
- data/lib/aspera/cli/error.rb +42 -0
- data/lib/aspera/cli/extended_value.rb +30 -5
- data/lib/aspera/cli/formatter.rb +118 -56
- data/lib/aspera/cli/hints.rb +0 -1
- data/lib/aspera/cli/http.rb +6 -5
- data/lib/aspera/cli/info.rb +10 -4
- data/lib/aspera/cli/mailer.rb +1 -1
- data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
- data/lib/aspera/cli/mcp/secrets.rb +143 -0
- data/lib/aspera/cli/mcp/tool.rb +231 -0
- data/lib/aspera/cli/option_value.rb +9 -1
- data/lib/aspera/cli/options.schema.yaml +390 -39
- data/lib/aspera/cli/parser.rb +14 -5
- data/lib/aspera/cli/plugins/aoc.rb +8 -11
- data/lib/aspera/cli/plugins/base.rb +36 -15
- data/lib/aspera/cli/plugins/config.rb +15 -44
- data/lib/aspera/cli/plugins/console.rb +3 -1
- data/lib/aspera/cli/plugins/factory.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +9 -19
- data/lib/aspera/cli/plugins/faspio.rb +36 -5
- data/lib/aspera/cli/plugins/httpgw.rb +1 -0
- data/lib/aspera/cli/plugins/mcp.rb +177 -118
- data/lib/aspera/cli/plugins/node.rb +14 -8
- data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
- data/lib/aspera/cli/plugins/server.rb +3 -1
- data/lib/aspera/cli/plugins/shares.rb +205 -35
- data/lib/aspera/cli/preset_actions.rb +15 -7
- data/lib/aspera/cli/preset_manager.rb +24 -2
- data/lib/aspera/cli/prompt.rb +3 -1
- data/lib/aspera/cli/result.rb +17 -4
- data/lib/aspera/cli/runner.rb +133 -43
- data/lib/aspera/cli/security.rb +134 -0
- data/lib/aspera/cli/table_fitter.rb +116 -0
- data/lib/aspera/cli/terminal_formatter.rb +23 -34
- data/lib/aspera/cli/transfer_agent.rb +4 -1
- data/lib/aspera/cli/vault_manager.rb +6 -5
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/web_secret.html.erb +61 -0
- data/lib/aspera/cli/web_secret.rb +220 -0
- data/lib/aspera/cli/wizard.rb +19 -9
- data/lib/aspera/command_line_builder.rb +26 -0
- data/lib/aspera/data_repository.rb +38 -24
- data/lib/aspera/duration.rb +42 -0
- data/lib/aspera/environment.rb +88 -24
- data/lib/aspera/exec_spec.rb +6 -5
- data/lib/aspera/faspex_gw.rb +98 -32
- data/lib/aspera/faspex_postproc.rb +187 -44
- data/lib/aspera/formatter_interface.rb +0 -1
- data/lib/aspera/id_generator.rb +2 -1
- data/lib/aspera/json_rpc/client.rb +51 -32
- data/lib/aspera/json_rpc/version.rb +1 -0
- data/lib/aspera/keychain/base.rb +5 -0
- data/lib/aspera/keychain/encrypted_hash.rb +6 -2
- data/lib/aspera/keychain/factory.rb +2 -3
- data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
- data/lib/aspera/keychain/macos_security.rb +119 -13
- data/lib/aspera/keychain/one_password_api.rb +8 -3
- data/lib/aspera/keychain/one_password_base.rb +14 -9
- data/lib/aspera/keychain/one_password_cli.rb +4 -2
- data/lib/aspera/log.rb +3 -6
- data/lib/aspera/markdown.rb +152 -115
- data/lib/aspera/mime.rb +6 -0
- data/lib/aspera/nagios.rb +3 -1
- data/lib/aspera/node_emulator.rb +98 -101
- data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
- data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
- data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
- data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
- data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
- data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
- data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
- data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
- data/lib/aspera/oauth/server/authorize.html.erb +34 -0
- data/lib/aspera/oauth/server/pkce.rb +426 -0
- data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/rainbow.rb +27 -1
- data/lib/aspera/rest/aspera_errors.rb +51 -59
- data/lib/aspera/rest/client.rb +21 -14
- data/lib/aspera/rest/error_analyzer.rb +9 -0
- data/lib/aspera/rest/list.rb +6 -6
- data/lib/aspera/rest/util.rb +7 -1
- data/lib/aspera/rest.rb +0 -1
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
- data/lib/aspera/schema/documentation.rb +50 -78
- data/lib/aspera/schema/reader.rb +39 -11
- data/lib/aspera/schema/registry.rb +8 -2
- data/lib/aspera/secret_hider.rb +94 -15
- data/lib/aspera/secret_strength.rb +114 -0
- data/lib/aspera/servlet_helpers.rb +111 -0
- data/lib/aspera/sync/args.schema.yaml +3 -1
- data/lib/aspera/sync/conf.schema.yaml +16 -6
- data/lib/aspera/sync/operations.rb +21 -1
- data/lib/aspera/temp_file_manager.rb +79 -20
- data/lib/aspera/transfer/parameters.rb +5 -0
- data/lib/aspera/transfer/spec.rb +52 -12
- data/lib/aspera/transfer/spec.schema.yaml +8 -5
- data/lib/aspera/web_auth/index.html.erb +16 -0
- data/lib/aspera/web_auth/server.rb +18 -10
- data/lib/aspera/web_page.html.erb +262 -0
- data/lib/aspera/web_page.rb +61 -0
- data/lib/aspera/web_server_simple.rb +76 -10
- data.tar.gz.sig +0 -0
- metadata +47 -32
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/mcp_tool.rb +0 -189
- data/lib/aspera/oauth.rb +0 -9
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
- data/lib/aspera/web_auth/index.html +0 -147
|
@@ -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 '
|
|
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
|
-
#
|
|
26
|
-
#
|
|
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","
|
|
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
|
|
115
|
+
SERVER_KEYS = %i[instructions description].freeze
|
|
75
116
|
# Keys forwarded to MCP::Configuration
|
|
76
|
-
CONFIG_KEYS
|
|
117
|
+
CONFIG_KEYS = %i[protocol_version validate_tool_call_arguments].freeze
|
|
77
118
|
# Keys forwarded to StdioTransport
|
|
78
|
-
STDIO_KEYS
|
|
119
|
+
STDIO_KEYS = %i[max_line_bytes].freeze
|
|
79
120
|
# Keys forwarded to StreamableHTTPTransport
|
|
80
|
-
HTTP_KEYS
|
|
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
|
|
83
|
-
|
|
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/
|
|
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
|
|
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::
|
|
94
|
-
Cli::
|
|
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
|
-
|
|
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::
|
|
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:
|
|
117
|
-
version:
|
|
118
|
-
tools:
|
|
119
|
-
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
|
-
#
|
|
139
|
-
|
|
140
|
-
#
|
|
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 '
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
|