Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions lib/msf/core/mcp/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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}"
Comment thread
zeroSteiner marked this conversation as resolved.
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"
Expand Down
16 changes: 16 additions & 0 deletions lib/msf/core/mcp/config/validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions lib/msf/core/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

##
Expand Down
31 changes: 24 additions & 7 deletions plugins/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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=<token> MCP server authentication token (default: random, blank: disabled)')
print_line(' ServerHost=<host> MCP server bind address (default: localhost)')
print_line(' ServerPort=<port> MCP server port (default: 3000)')
print_line(' RpcHost=<host> RPC server host (default: 127.0.0.1)')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Comment thread
zeroSteiner marked this conversation as resolved.
auth_token_generated = false
else
auth_token = @mcp_server.class.generate_auth_token
auth_token_generated = true
end
Comment thread
zeroSteiner marked this conversation as resolved.

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)
Comment thread
zeroSteiner marked this conversation as resolved.
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
Comment on lines 301 to +312

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While this code is duplicated, I took care so that what is printed is consistent between the plugin and the msfmcpd stand-alone utility, so it's not visually obvious that they're different.

rescue Msf::MCP::Metasploit::AuthenticationError => e
raise Msf::MCP::Metasploit::AuthenticationError, "RPC authentication failed: #{e.message}"
rescue Msf::MCP::Metasploit::ConnectionError => e
Expand All @@ -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

Expand Down Expand Up @@ -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)

{
Expand Down
6 changes: 5 additions & 1 deletion spec/lib/msf/core/mcp/application_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions spec/lib/msf/core/mcp/config/validator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
6 changes: 5 additions & 1 deletion spec/plugins/mcp/console_dispatcher_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
67 changes: 66 additions & 1 deletion spec/plugins/mcp_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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({})
Expand Down
Loading