Path: blob/master/spec/plugins/mcp_spec.rb
74479 views
# frozen_string_literal: true12require 'spec_helper'3require 'rex/text'4require Metasploit::Framework.root.join('plugins/mcp.rb').to_path56RSpec.describe Msf::Plugin::MCP do7include_context 'Msf::UIDriver'89let(:framework) { instance_double(Msf::Framework) }10let(:framework_datastore) { { 'VERBOSE' => false } }11let(:output) { driver_output }12let(:base_opts) { { 'LocalOutput' => output } }1314let(:mock_thread) do15instance_double(Thread, alive?: false, join: true, kill: nil)16end1718let(:threads_manager) do19instance_double('Msf::Framework::ThreadManager').tap do |tm|20allow(tm).to receive(:spawn).and_return(mock_thread)21end22end2324let(:plugins_collection) do25instance_double('Msf::PluginManager').tap do |pm|26allow(pm).to receive(:find).and_return(nil)27allow(pm).to receive(:load).and_return(true)28allow(pm).to receive(:unload).and_return(true)29end30end3132let(:mock_client_class) do33Class.new do34def initialize(**_args); end3536def authenticate(*_args)37'token'38end39end40end4142let(:mock_rate_limiter_class) do43Class.new do44def initialize(**_args); end45end46end4748let(:mock_server_class) do49Class.new do50def initialize(**_args); end51def start(**_args); end52def shutdown; end53end54end5556before do57allow(framework).to receive(:threads).and_return(threads_manager)58allow(framework).to receive(:plugins).and_return(plugins_collection)59allow(framework).to receive(:datastore).and_return(framework_datastore)6061stub_const('Msf::MCP::Metasploit::Client', mock_client_class)62stub_const('Msf::MCP::Metasploit::AuthenticationError', Class.new(StandardError))63stub_const('Msf::MCP::Metasploit::ConnectionError', Class.new(StandardError))64stub_const('Msf::MCP::Security::RateLimiter', mock_rate_limiter_class)65stub_const('Msf::MCP::Server', mock_server_class)6667allow(Rex::Text).to receive(:rand_text_alphanumeric).with(12).and_return('abcdefghijkl')68allow_any_instance_of(described_class).to receive(:sleep)6970mock_dispatcher = instance_double(Msf::Plugin::MCP::McpCommandDispatcher)71allow(mock_dispatcher).to receive(:plugin=)72allow_any_instance_of(Msf::Plugin::MCP).to receive(:add_console_dispatcher).and_return(mock_dispatcher)73allow_any_instance_of(Msf::Plugin::MCP).to receive(:remove_console_dispatcher)74end7576describe '#initialize' do77subject(:plugin) { described_class.new(framework, base_opts) }7879it 'creates the plugin successfully without starting the server' do80expect { plugin }.not_to raise_error81end8283it 'does not create an MCP client' do84expect(mock_client_class).not_to receive(:new)85plugin86end8788it 'does not spawn a server thread' do89expect(threads_manager).not_to receive(:spawn)90plugin91end9293it 'prints a message about using mcp start' do94plugin95expect(@output.join("\n")).to include('mcp start')96end9798it 'registers the command dispatcher' do99expect_any_instance_of(described_class).to receive(:add_console_dispatcher)100.with(described_class::McpCommandDispatcher)101.and_return(instance_double(described_class::McpCommandDispatcher, :plugin= => nil))102plugin103end104105it 'has nil server_config' do106expect(plugin.server_config).to be_nil107end108end109110describe '#start_server' do111subject(:plugin) { described_class.new(framework, base_opts) }112113context 'with default options' do114before { plugin.start_server({}) }115116it 'creates an MCP client' do117expect(plugin.msf_client).not_to be_nil118end119120it 'starts the server in a background thread' do121expect(threads_manager).to have_received(:spawn).with('MCPServer', false)122end123124it 'prints a status message with the listening address' do125expect(@output.join("\n")).to include('MCP server started on localhost:3000 (transport: http)')126end127128it 'stores the server configuration' do129expect(plugin.server_config).to be_a(Hash)130expect(plugin.server_config[:mcp][:transport]).to eq('http')131end132end133134context 'with custom options' do135before { plugin.start_server('ServerHost' => '0.0.0.0', 'ServerPort' => '8080') }136137it 'applies the provided host and port' do138expect(plugin.server_config[:mcp][:host]).to eq('0.0.0.0')139expect(plugin.server_config[:mcp][:port]).to eq(8080)140end141end142143context 'with stdio transport' do144it 'rejects stdio as an unknown option since Transport is no longer accepted' do145dispatcher = Msf::Plugin::MCP::McpCommandDispatcher.new(driver)146dispatcher.plugin = plugin147capture_logging(dispatcher)148dispatcher.cmd_mcp('start', 'Transport=stdio')149expect(@error.join("\n")).to include('Unknown option: Transport')150end151end152153context 'when server is already running' do154before do155plugin.start_server({})156reset_logging!157end158159it 'prints an error and does not restart' do160plugin.start_server({})161expect(@error.join("\n")).to include('MCP server is already running')162end163end164165context 'when port is already in use' do166let(:failing_server_class) do167Class.new do168def initialize(**_args); end169170def start(**_args)171raise Errno::EADDRINUSE172end173174def shutdown; end175end176end177178before do179stub_const('Msf::MCP::Server', failing_server_class)180allow(threads_manager).to receive(:spawn) do |_name, _critical, &block|181block.call182end183end184185it 'prints an error with address-in-use message' do186plugin.start_server({})187expect(@error.join("\n")).to include('Address already in use')188end189end190191context 'when RPC authentication fails' do192let(:failing_client_class) do193error_class = Msf::MCP::Metasploit::AuthenticationError194Class.new do195define_method(:initialize) { |**_args| }196define_method(:authenticate) { |*_args| raise error_class, 'bad credentials' }197end198end199200before do201stub_const('Msf::MCP::Metasploit::Client', failing_client_class)202end203204it 'prints an error with authentication failure message' do205plugin.start_server({})206expect(@error.join("\n")).to include('RPC authentication failed')207end208end209210context 'when RPC connection fails' do211let(:failing_client_class) do212error_class = Msf::MCP::Metasploit::ConnectionError213Class.new do214define_method(:initialize) { |**_args| }215define_method(:authenticate) { |*_args| raise error_class, 'connection refused' }216end217end218219before do220stub_const('Msf::MCP::Metasploit::Client', failing_client_class)221end222223it 'prints an error with connection failure message' do224plugin.start_server({})225expect(@error.join("\n")).to include('RPC connection failed')226end227228it 'unloads the auto-started msgrpc plugin on failure' do229msgrpc_plugin = instance_double('Msf::Plugin::MSGRPC', name: 'msgrpc')230call_count = 0231allow(plugins_collection).to receive(:find) do232call_count += 1233call_count > 1 ? msgrpc_plugin : nil234end235expect(plugins_collection).to receive(:unload).with(msgrpc_plugin)236plugin.start_server({})237end238end239end240241describe '#stop_server' do242subject(:plugin) { described_class.new(framework, base_opts) }243244context 'when server is running' do245before do246plugin.start_server({})247reset_logging!248end249250it 'stops the server and prints a message' do251plugin.stop_server252expect(@output.join("\n")).to include('MCP server stopped')253end254255it 'nils out the server reference' do256plugin.stop_server257expect(plugin.mcp_server).to be_nil258end259260it 'nils out the client reference' do261plugin.stop_server262expect(plugin.msf_client).to be_nil263end264end265266context 'when server is not running' do267it 'prints an error' do268plugin.stop_server269expect(@error.join("\n")).to include('MCP server is already stopped')270end271end272end273274describe '#restart_server' do275subject(:plugin) { described_class.new(framework, base_opts) }276277context 'when server is running' do278before do279plugin.start_server({})280reset_logging!281end282283it 'restarts with new options' do284plugin.restart_server('ServerPort' => '9090')285expect(plugin.server_config[:mcp][:port]).to eq(9090)286end287end288289context 'when server is not running' do290it 'starts the server fresh' do291plugin.restart_server('ServerPort' => '8080')292expect(plugin.server_config[:mcp][:port]).to eq(8080)293expect(plugin.mcp_server).not_to be_nil294end295end296end297298describe '#print_mcp_status' do299subject(:plugin) { described_class.new(framework, base_opts) }300301context 'when server has never been configured' do302it 'prints not configured status' do303plugin.print_mcp_status304expect(@output.join("\n")).to include('stopped (not configured)')305end306end307308context 'when server is running' do309before do310plugin.start_server({})311reset_logging!312end313314it 'prints running status with details' do315plugin.print_mcp_status316combined = @output.join("\n")317expect(combined).to include('MCP server status: running')318expect(combined).to include('http://localhost:3000')319end320end321322context 'when server was started then stopped' do323before do324plugin.start_server({})325plugin.stop_server326reset_logging!327end328329it 'prints stopped status with last known config' do330plugin.print_mcp_status331combined = @output.join("\n")332expect(combined).to include('MCP server status: stopped')333end334end335end336337describe '#cleanup' do338subject(:plugin) { described_class.new(framework, base_opts) }339340before do341allow(plugin).to receive(:remove_console_dispatcher)342end343344context 'when server is running' do345before do346plugin.start_server({})347reset_logging!348end349350it 'shuts down the MCP server' do351server = plugin.mcp_server352expect(server).to receive(:shutdown)353plugin.cleanup354end355356it 'prints a stop message' do357plugin.cleanup358expect(@output.join("\n")).to include('MCP server stopped')359end360361it 'nils out all references' do362plugin.cleanup363expect(plugin.mcp_server).to be_nil364expect(plugin.server_thread).to be_nil365expect(plugin.msf_client).to be_nil366expect(plugin.rate_limiter).to be_nil367expect(plugin.started_at).to be_nil368end369end370371context 'when server was never started' do372it 'deregisters the console dispatcher without error' do373expect(plugin).to receive(:remove_console_dispatcher).with('MCP')374expect { plugin.cleanup }.not_to raise_error375end376end377378context 'when msgrpc was auto-started' do379before { plugin.start_server({}) }380381it 'unloads the auto-started msgrpc plugin' do382plugin.auto_started_rpc = true383msgrpc_plugin = instance_double('Msf::Plugin::MSGRPC', name: 'msgrpc')384allow(plugins_collection).to receive(:find).and_return(msgrpc_plugin)385expect(plugins_collection).to receive(:unload).with(msgrpc_plugin)386plugin.cleanup387end388end389390context 'when msgrpc was pre-existing' do391before { plugin.start_server({}) }392393it 'does not unload the msgrpc plugin' do394plugin.auto_started_rpc = false395expect(plugins_collection).not_to receive(:unload)396plugin.cleanup397end398end399400context 'when msgrpc unload fails' do401before { plugin.start_server({}) }402403it 'prints a warning and continues cleanup' do404plugin.auto_started_rpc = true405msgrpc_plugin = instance_double('Msf::Plugin::MSGRPC', name: 'msgrpc')406allow(plugins_collection).to receive(:find).and_return(msgrpc_plugin)407allow(plugins_collection).to receive(:unload).and_raise(StandardError, 'unload failed')408409plugin.cleanup410411expect(@error.join("\n")).to include('Failed to unload auto-started msgrpc')412expect(plugin.mcp_server).to be_nil413end414end415end416417describe '#terminate_server_thread' do418subject(:plugin) { described_class.new(framework, base_opts) }419420context 'when thread terminates within 5 seconds' do421it 'does not force kill the thread' do422alive_thread = instance_double(Thread, alive?: true, join: true)423plugin.server_thread = alive_thread424expect(alive_thread).not_to receive(:kill)425plugin.send(:terminate_server_thread)426end427end428429context 'when thread does not terminate within 5 seconds' do430it 'force kills the thread' do431stuck_thread = instance_double(Thread, alive?: true, join: nil, kill: nil)432plugin.server_thread = stuck_thread433expect(stuck_thread).to receive(:kill)434plugin.send(:terminate_server_thread)435end436437it 'prints a warning message' do438stuck_thread = instance_double(Thread, alive?: true, join: nil, kill: nil)439plugin.server_thread = stuck_thread440plugin.send(:terminate_server_thread)441expect(@error.join("\n")).to include('did not terminate gracefully')442end443end444445context 'when thread is already dead' do446it 'does nothing' do447dead_thread = instance_double(Thread, alive?: false)448plugin.server_thread = dead_thread449expect(dead_thread).not_to receive(:join)450expect(dead_thread).not_to receive(:kill)451plugin.send(:terminate_server_thread)452end453end454455context 'when server_thread is nil' do456it 'does nothing' do457plugin.server_thread = nil458expect { plugin.send(:terminate_server_thread) }.not_to raise_error459end460end461end462463describe 'RPC resolution' do464subject(:plugin) { described_class.new(framework, base_opts) }465466context 'with explicit RPC credentials' do467it 'uses the provided credentials' do468plugin.start_server('RpcUser' => 'admin', 'RpcPass' => 'secret123')469expect(plugin.server_config[:rpc][:user]).to eq('admin')470expect(plugin.server_config[:rpc][:pass]).to eq('secret123')471end472end473474context 'when msgrpc is already loaded (introspection path)' do475let(:mock_msgrpc_server) do476instance_double('Msf::RPC::Service').tap do |s|477allow(s).to receive(:users).and_return({ 'admin' => 'secretpass' })478allow(s).to receive(:srvhost).and_return('127.0.0.1')479allow(s).to receive(:srvport).and_return(55_553)480allow(s).to receive(:options).and_return({ ssl: true })481end482end483484let(:mock_msgrpc_plugin) do485instance_double('Msf::Plugin::MSGRPC', name: 'msgrpc', server: mock_msgrpc_server)486end487488before do489allow(plugins_collection).to receive(:find) do |&block|490[mock_msgrpc_plugin].find(&block)491end492end493494it 'uses credentials from the loaded msgrpc plugin' do495plugin.start_server({})496expect(plugin.server_config[:rpc][:user]).to eq('admin')497expect(plugin.server_config[:rpc][:pass]).to eq('secretpass')498end499500it 'uses host and port from the loaded msgrpc plugin' do501plugin.start_server({})502expect(plugin.server_config[:rpc][:host]).to eq('127.0.0.1')503expect(plugin.server_config[:rpc][:port]).to eq(55_553)504end505506it 'does not auto-start msgrpc' do507expect(plugins_collection).not_to receive(:load)508plugin.start_server({})509end510511it 'does not set auto_started_rpc flag' do512plugin.start_server({})513expect(plugin.auto_started_rpc).to be false514end515end516517context 'when no msgrpc is loaded and no creds provided (auto-start path)' do518it 'auto-starts msgrpc and prints credentials' do519expect(plugins_collection).to receive(:load).with('msgrpc', hash_including('Pass' => 'abcdefghijkl', 'User' => 'msf'))520plugin.start_server({})521expect(@output.join("\n")).to include('Auto-started msgrpc')522expect(@output.join("\n")).to include('abcdefghijkl')523end524525it 'sets auto_started_rpc flag' do526plugin.start_server({})527expect(plugin.auto_started_rpc).to be true528end529end530end531532describe 'option validation' do533subject(:plugin) { described_class.new(framework, base_opts) }534535context 'with invalid ServerPort' do536it 'prints an error' do537plugin.start_server('ServerPort' => '99999')538expect(@error.join("\n")).to include('Invalid value for ServerPort')539end540end541542context 'with invalid Transport' do543it 'rejects Transport as an unknown option' do544dispatcher = Msf::Plugin::MCP::McpCommandDispatcher.new(driver)545dispatcher.plugin = plugin546capture_logging(dispatcher)547dispatcher.cmd_mcp('start', 'Transport=websocket')548expect(@error.join("\n")).to include('Unknown option: Transport')549end550end551552context 'with invalid RpcSSL' do553it 'prints an error' do554plugin.start_server('RpcSSL' => 'maybe')555expect(@error.join("\n")).to include('Invalid value for RpcSSL')556end557end558559context 'with invalid RateLimit' do560it 'prints an error' do561plugin.start_server('RateLimit' => '0')562expect(@error.join("\n")).to include('Invalid value for RateLimit')563end564end565566context 'with RpcUser but no RpcPass' do567it 'prints an error' do568plugin.start_server('RpcUser' => 'admin')569expect(@error.join("\n")).to include('Invalid value for RpcPass')570end571end572573context 'with RpcPass but no RpcUser' do574it 'prints an error' do575plugin.start_server('RpcPass' => 'secret')576expect(@error.join("\n")).to include('Invalid value for RpcUser')577end578end579end580581describe Msf::Plugin::MCP::McpCommandDispatcher do582let(:plugin_instance) { Msf::Plugin::MCP.new(framework, base_opts) }583let(:dispatcher) do584d = Msf::Plugin::MCP::McpCommandDispatcher.new(driver)585d.plugin = plugin_instance586d587end588589before do590capture_logging(dispatcher)591end592593describe '#cmd_mcp with start subcommand' do594it 'parses Key=Value options and passes them to start_server' do595expect(plugin_instance).to receive(:start_server) do |opts|596expect(opts).to eq('RateLimit' => '120')597end598dispatcher.cmd_mcp('start', 'RateLimit=120')599end600601it 'starts with empty opts when no options given' do602expect(plugin_instance).to receive(:start_server) do |opts|603expect(opts).to eq({})604end605dispatcher.cmd_mcp('start')606end607608it 'rejects malformed options' do609expect(plugin_instance).not_to receive(:start_server)610dispatcher.cmd_mcp('start', 'badoption')611expect(@error.join("\n")).to include('Invalid option format')612end613614it 'rejects unknown option keys' do615expect(plugin_instance).not_to receive(:start_server)616dispatcher.cmd_mcp('start', 'FakeOption=value')617expect(@error.join("\n")).to include('Unknown option: FakeOption')618end619end620621describe '#cmd_mcp with restart subcommand' do622it 'parses options and passes them to restart_server' do623expect(plugin_instance).to receive(:restart_server) do |opts|624expect(opts).to eq('ServerPort' => '9090')625end626dispatcher.cmd_mcp('restart', 'ServerPort=9090')627end628end629630describe '#cmd_mcp with help subcommand' do631it 'prints usage information including option examples' do632dispatcher.cmd_mcp('help')633combined = @output.join("\n")634expect(combined).to include('Usage: mcp <subcommand> [options]')635expect(combined).to include('ServerHost=<host>')636expect(combined).to include('mcp start RpcUser=msf RpcPass=secret')637end638end639640describe '#cmd_mcp_tabs' do641it 'returns subcommands when typing subcommand' do642# User typed: "mcp <tab>" → words = ['mcp'], str = ''643result = dispatcher.cmd_mcp_tabs('', ['mcp'])644expect(result).to contain_exactly('status', 'start', 'stop', 'restart', 'help')645end646647it 'returns option completions for start subcommand' do648# User typed: "mcp start <tab>" → words = ['mcp', 'start'], str = ''649result = dispatcher.cmd_mcp_tabs('', ['mcp', 'start'])650expect(result).to include('ServerHost=', 'ServerPort=', 'RpcHost=', 'RpcPass=')651end652653it 'filters options by partial input' do654# User typed: "mcp start Rpc<tab>" → words = ['mcp', 'start'], str = 'Rpc'655result = dispatcher.cmd_mcp_tabs('Rpc', ['mcp', 'start'])656expect(result).to contain_exactly('RpcHost=', 'RpcPort=', 'RpcUser=', 'RpcPass=', 'RpcSSL=')657end658659it 'filters options case-insensitively' do660# User typed: "mcp start rpc<tab>" → words = ['mcp', 'start'], str = 'rpc'661result = dispatcher.cmd_mcp_tabs('rpc', ['mcp', 'start'])662expect(result).to contain_exactly('RpcHost=', 'RpcPort=', 'RpcUser=', 'RpcPass=', 'RpcSSL=')663end664665it 'returns empty for non-start/restart subcommands' do666# User typed: "mcp status <tab>" → words = ['mcp', 'status'], str = ''667result = dispatcher.cmd_mcp_tabs('', ['mcp', 'status'])668expect(result).to eq([])669end670end671end672end673674675