diff --git a/lib/msf/core/mcp/application.rb b/lib/msf/core/mcp/application.rb index 97545819f6f84..d859dd4a7b20c 100644 --- a/lib/msf/core/mcp/application.rb +++ b/lib/msf/core/mcp/application.rb @@ -282,7 +282,7 @@ def start_mcp_server if transport == :http @output.puts "Starting MCP server on HTTP transport..." - @output.puts "Server listening on http://#{Rex::Socket.to_authority(host, port)}/" + @output.puts "MCP server listening on http://#{Rex::Socket.to_authority(host, port)}/" auth_token = resolve_auth_token case auth_token when :disabled @@ -292,7 +292,7 @@ def start_mcp_server @output.puts "Authentication: enabled" auth_token = @config.dig(:mcp, :auth_token) when :generated - auth_token = SecureRandom.hex(32) + auth_token = @mcp_server.class.generate_auth_token @output.puts "Authentication: Bearer token (auto-generated)" @output.puts " Configure your MCP client with: Authorization: Bearer #{auth_token}" else @@ -304,15 +304,24 @@ def start_mcp_server max_threads = @config.dig(:mcp, :max_threads) || Msf::MCP::Server::PUMA_MAX_THREADS workers = @config.dig(:mcp, :workers) || Msf::MCP::Server::PUMA_WORKERS - @mcp_server.start( - transport: :http, - host: host, - port: port, - auth_token: auth_token, - min_threads: min_threads, - max_threads: max_threads, - workers: workers - ) + begin + @mcp_server.start( + transport: :http, + host: host, + port: port, + auth_token: auth_token, + min_threads: min_threads, + max_threads: max_threads, + workers: workers + ) + rescue Errno::EADDRINUSE => e + @output.puts "#{e.class}: #{e.message}" + elog({ + message: 'Cannot start the MCP server', + context: { host: host, port: port }, + exception: e + }, LOG_SOURCE, LOG_ERROR) + end else @output.puts "Starting MCP server on stdio transport..." @output.puts "Server ready - waiting for MCP requests" diff --git a/lib/msf/core/mcp/config/validator.rb b/lib/msf/core/mcp/config/validator.rb index fb613417d17dc..8d32ace36f5e7 100644 --- a/lib/msf/core/mcp/config/validator.rb +++ b/lib/msf/core/mcp/config/validator.rb @@ -75,6 +75,22 @@ def validate!(config) end end + # Validate MCP authentication token + if config[:mcp].is_a?(Hash) && config[:mcp].key?(:auth_token) + auth_token = config[:mcp][:auth_token] + if auth_token.is_a?(String) + unless auth_token.match?(/\A[!-~]*\z/) + errors[:'mcp.auth_token'] = "must be a valid token" + end + elsif auth_token != nil + errors[:'mcp.auth_token'] = "must be a string" + end + + unless config[:mcp][:transport] == 'http' + errors[:'mcp.auth_token'] = "authentication must only be used with the 'http' transport" + end + end + # Validate MCP Puma thread/worker settings if config[:mcp].is_a?(Hash) if config[:mcp].key?(:min_threads) diff --git a/lib/msf/core/mcp/server.rb b/lib/msf/core/mcp/server.rb index 864a3c45c9d9d..78f220cf3a7a2 100644 --- a/lib/msf/core/mcp/server.rb +++ b/lib/msf/core/mcp/server.rb @@ -98,6 +98,12 @@ def shutdown @mcp_server = nil end + # Generate a random authentication token + # @return [String] + def self.generate_auth_token + SecureRandom.hex(32) + end + private ## diff --git a/plugins/mcp.rb b/plugins/mcp.rb index 7145c98eec975..f64e3c5e8d9f0 100644 --- a/plugins/mcp.rb +++ b/plugins/mcp.rb @@ -23,6 +23,7 @@ class McpCommandDispatcher # Valid option keys accepted by `mcp start` and `mcp restart` unless defined?(VALID_OPTIONS) VALID_OPTIONS = %w[ + AuthToken ServerHost ServerPort RpcHost RpcPort RpcUser RpcPass RpcSSL RateLimit @@ -67,6 +68,7 @@ def cmd_mcp_help print_line(' help - Show this help message') print_line print_line('Options (for start/restart):') + print_line(' AuthToken= MCP server authentication token (default: random, blank: disabled)') print_line(' ServerHost= MCP server bind address (default: localhost)') print_line(' ServerPort= MCP server port (default: 3000)') print_line(' RpcHost= RPC server host (default: 127.0.0.1)') @@ -130,7 +132,8 @@ def parse_options(args) opts = {} args.each do |arg| key, value = arg.split('=', 2) - unless key && value && !value.empty? + # AuthToken can be blank while the others have to be set (blank is explicitly disabled) + unless key && value && (key.casecmp('AuthToken').zero? || !value.empty?) print_error("Invalid option format: #{arg} (expected Key=Value)") return nil end @@ -286,13 +289,27 @@ def start_mcp_server(rpc, config) host = mcp_config[:host] port = mcp_config[:port] + if mcp_config.key?(:auth_token) + auth_token = mcp_config[:auth_token] + auth_token_generated = false + else + auth_token = @mcp_server.class.generate_auth_token + auth_token_generated = true + end + print_status('Starting MCP server on HTTP transport...') @server_thread = framework.threads.spawn('MCPServer', false) do - @mcp_server.start(transport: :http, host: host, port: port) + @mcp_server.start(transport: :http, host: host, port: port, auth_token: auth_token) end @started_at = Time.now - print_server_status(mcp_config) + print_status("MCP server listening on http://#{Rex::Socket.to_authority(mcp_config[:host], mcp_config[:port])}/") + if auth_token_generated + print_status("Authentication: Bearer token (auto-generated)") + print_status(" Configure your MCP client with: Authorization: Bearer #{auth_token}") + else + print_status("Authentication: #{auth_token ? 'enabled' : 'disabled'}") + end rescue Msf::MCP::Metasploit::AuthenticationError => e raise Msf::MCP::Metasploit::AuthenticationError, "RPC authentication failed: #{e.message}" rescue Msf::MCP::Metasploit::ConnectionError => e @@ -301,10 +318,6 @@ def start_mcp_server(rpc, config) raise Msf::MCP::Error, "Address already in use: #{Rex::Socket.to_authority(mcp_config[:host], mcp_config[:port])}" end - def print_server_status(mcp_config) - print_status("MCP server started on #{Rex::Socket.to_authority(mcp_config[:host], mcp_config[:port])} (transport: http)") - end - def format_uptime return 'N/A' unless @started_at @@ -442,6 +455,10 @@ def resolve_config(opts) port: Integer(opts['ServerPort'] || Msf::MCP::Config::Defaults::MCP_PORT) } + if opts.key?('AuthToken') + mcp_config[:auth_token] = opts['AuthToken'].blank? ? nil : opts['AuthToken'] + end + rate_limit_value = Integer(opts['RateLimit'] || Msf::MCP::Config::Defaults::RATE_LIMIT_REQUESTS_PER_MINUTE) { diff --git a/spec/lib/msf/core/mcp/application_spec.rb b/spec/lib/msf/core/mcp/application_spec.rb index cbaeae1fe6489..0cceee3504d04 100644 --- a/spec/lib/msf/core/mcp/application_spec.rb +++ b/spec/lib/msf/core/mcp/application_spec.rb @@ -376,6 +376,10 @@ describe '#start_mcp_server' do let(:mock_mcp_server) { instance_double(Msf::MCP::Server) } + before do + allow(mock_mcp_server).to receive(:class).and_return(Msf::MCP::Server) + end + it 'starts server with stdio transport by default' do app = described_class.new([], output: output) app.instance_variable_set(:@config, valid_config) @@ -413,7 +417,7 @@ app.send(:start_mcp_server) expect(output.string).to include('Starting MCP server on HTTP transport...') - expect(output.string).to include('Server listening on http://0.0.0.0:3000') + expect(output.string).to include('MCP server listening on http://0.0.0.0:3000') end it 'uses default host and port for HTTP transport' do diff --git a/spec/lib/msf/core/mcp/config/validator_spec.rb b/spec/lib/msf/core/mcp/config/validator_spec.rb index 73d894762f2d8..10a92c823b974 100644 --- a/spec/lib/msf/core/mcp/config/validator_spec.rb +++ b/spec/lib/msf/core/mcp/config/validator_spec.rb @@ -173,6 +173,52 @@ end end + context 'with MCP auth token validation' do + let(:base_config) do + { + msf_api: { + type: 'messagepack', + host: 'localhost', + user: 'msf', + password: 'password' + }, + mcp: { + transport: 'http' + } + } + end + + it 'accepts nil, empty, and printable non-whitespace string tokens' do + [nil, '', 'password', 'abc_123-XYZ.~'].each do |auth_token| + config = base_config.merge(mcp: base_config[:mcp].merge(auth_token: auth_token)) + expect(described_class.validate!(config)).to be true + end + end + + it 'rejects non-string tokens' do + config = base_config.merge(mcp: base_config[:mcp].merge(auth_token: 123)) + + expect { described_class.validate!(config) }.to raise_error(Msf::MCP::Config::ValidationError, /mcp\.auth_token must be a string/) + end + + it 'rejects whitespace and non-printable tokens' do + ['with space', "tab\t", "newline\n", "bad\x7f"].each do |auth_token| + config = base_config.merge(mcp: base_config[:mcp].merge(auth_token: auth_token)) + + expect { described_class.validate!(config) }.to raise_error(Msf::MCP::Config::ValidationError, /mcp\.auth_token must be a valid token/) + end + end + + it 'rejects auth tokens for stdio transport' do + config = base_config.merge(mcp: { transport: 'stdio', auth_token: 'password' }) + + expect { described_class.validate!(config) }.to raise_error( + Msf::MCP::Config::ValidationError, + /mcp\.auth_token authentication must only be used with the 'http' transport/ + ) + end + end + context 'with Puma thread/worker validation' do let(:valid_base) do { diff --git a/spec/plugins/mcp/console_dispatcher_spec.rb b/spec/plugins/mcp/console_dispatcher_spec.rb index b7533715e2f12..07cc6db56828e 100644 --- a/spec/plugins/mcp/console_dispatcher_spec.rb +++ b/spec/plugins/mcp/console_dispatcher_spec.rb @@ -46,6 +46,10 @@ def initialize(**_args); end let(:mock_server_class) do Class.new do + def self.generate_auth_token + 'a' * 64 + end + def initialize(**_args); end def start(**_args); end @@ -249,7 +253,7 @@ def shutdown; end context 'when server is stopped' do it 'starts the server successfully' do mcp_plugin.start_server({}) - expect(@output.join("\n")).to include('MCP server started') + expect(@output.join("\n")).to include('MCP server listening') end it 'sets the server instance' do diff --git a/spec/plugins/mcp_spec.rb b/spec/plugins/mcp_spec.rb index 6e79a961f77a7..38146d4f061ab 100644 --- a/spec/plugins/mcp_spec.rb +++ b/spec/plugins/mcp_spec.rb @@ -48,7 +48,12 @@ def initialize(**_args); end let(:mock_server_class) do Class.new do + def self.generate_auth_token + 'a' * 64 + end + def initialize(**_args); end + def start(**_args); end def shutdown; end end @@ -123,7 +128,7 @@ def shutdown; end end it 'prints a status message with the listening address' do - expect(@output.join("\n")).to include('MCP server started on localhost:3000 (transport: http)') + expect(@output.join("\n")).to include('MCP server listening on http://localhost:3000/') end it 'stores the server configuration' do @@ -141,6 +146,47 @@ def shutdown; end end end + context 'with AuthToken options' do + let(:mock_server) { instance_double(mock_server_class, shutdown: nil) } + + before do + allow(mock_server_class).to receive(:new).and_return(mock_server) + allow(mock_server).to receive(:class).and_return(mock_server_class) + allow(threads_manager).to receive(:spawn) do |_name, _critical, &block| + block.call + mock_thread + end + end + + it 'generates a bearer token when AuthToken is omitted' do + expect(mock_server).to receive(:start).with(hash_including(auth_token: 'a' * 64)) + + plugin.start_server({}) + + expect(@output.join("\n")).to include('Authentication: Bearer token (auto-generated)') + expect(@output.join("\n")).to include("Authorization: Bearer #{'a' * 64}") + end + + it 'uses an explicit AuthToken without printing it' do + expect(mock_server_class).not_to receive(:generate_auth_token) + expect(mock_server).to receive(:start).with(hash_including(auth_token: 'custom-token')) + + plugin.start_server('AuthToken' => 'custom-token') + + expect(@output.join("\n")).to include('Authentication: enabled') + expect(@output.join("\n")).not_to include('custom-token') + end + + it 'disables authentication when AuthToken is blank' do + expect(mock_server_class).not_to receive(:generate_auth_token) + expect(mock_server).to receive(:start).with(hash_including(auth_token: nil)) + + plugin.start_server('AuthToken' => '') + + expect(@output.join("\n")).to include('Authentication: disabled') + end + end + context 'with stdio transport' do it 'rejects stdio as an unknown option since Transport is no longer accepted' do dispatcher = Msf::Plugin::MCP::McpCommandDispatcher.new(driver) @@ -166,6 +212,10 @@ def shutdown; end context 'when port is already in use' do let(:failing_server_class) do Class.new do + def self.generate_auth_token + 'a' * 64 + end + def initialize(**_args); end def start(**_args) @@ -599,6 +649,21 @@ def shutdown; end dispatcher.cmd_mcp('start', 'RateLimit=120') end + it 'parses AuthToken values including blank and case-insensitive keys' do + parsed_options = [] + allow(plugin_instance).to receive(:start_server) { |opts| parsed_options << opts } + + dispatcher.cmd_mcp('start', 'AuthToken=') + dispatcher.cmd_mcp('start', 'AuthToken=custom-token') + dispatcher.cmd_mcp('start', 'authtoken=') + + expect(parsed_options).to eq([ + { 'AuthToken' => '' }, + { 'AuthToken' => 'custom-token' }, + { 'AuthToken' => '' } + ]) + end + it 'starts with empty opts when no options given' do expect(plugin_instance).to receive(:start_server) do |opts| expect(opts).to eq({})