Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
rapid7
GitHub Repository: rapid7/metasploit-framework
Path: blob/master/plugins/mcp.rb
74450 views
1
# frozen_string_literal: true
2
3
require 'msf/core/mcp'
4
5
module Msf
6
###
7
#
8
# This plugin manages the lifecycle of the Metasploit MCP (Model Context Protocol)
9
# server from within the msfconsole session.
10
#
11
###
12
class Plugin::MCP < Msf::Plugin
13
14
#
15
# Console command dispatcher for the `mcp` command and its subcommands.
16
#
17
class McpCommandDispatcher
18
include Msf::Ui::Console::CommandDispatcher
19
20
# Guard against redefinition when msfconsole's plugin system loads the file twice
21
SUBCOMMANDS = %w[status start stop restart help].freeze unless defined?(SUBCOMMANDS)
22
23
# Valid option keys accepted by `mcp start` and `mcp restart`
24
unless defined?(VALID_OPTIONS)
25
VALID_OPTIONS = %w[
26
ServerHost ServerPort
27
RpcHost RpcPort RpcUser RpcPass RpcSSL
28
RateLimit
29
].freeze
30
end
31
32
attr_accessor :plugin
33
34
def name
35
'MCP'
36
end
37
38
def commands
39
{ 'mcp' => 'Manage the MCP server' }
40
end
41
42
def cmd_mcp(*args)
43
subcommand = args.shift
44
45
case subcommand
46
when 'status'
47
mcp_status
48
when 'start'
49
mcp_start(args)
50
when 'stop'
51
mcp_stop
52
when 'restart'
53
mcp_restart(args)
54
else
55
cmd_mcp_help
56
end
57
end
58
59
def cmd_mcp_help
60
print_line('Usage: mcp <subcommand> [options]')
61
print_line
62
print_line('Subcommands:')
63
print_line(' status - Display MCP server status')
64
print_line(' start - Start the MCP server')
65
print_line(' stop - Stop the MCP server')
66
print_line(' restart - Restart the MCP server')
67
print_line(' help - Show this help message')
68
print_line
69
print_line('Options (for start/restart):')
70
print_line(' ServerHost=<host> MCP server bind address (default: localhost)')
71
print_line(' ServerPort=<port> MCP server port (default: 3000)')
72
print_line(' RpcHost=<host> RPC server host (default: 127.0.0.1)')
73
print_line(' RpcPort=<port> RPC server port (default: 55552)')
74
print_line(' RpcUser=<user> RPC username (default: msf)')
75
print_line(' RpcPass=<pass> RPC password')
76
print_line(' RpcSSL=<true|false> Use SSL for RPC (default: false)')
77
print_line(' RateLimit=<n> Requests per minute (default: 60)')
78
print_line
79
print_line('Examples:')
80
print_line(' mcp start')
81
print_line(' mcp start ServerPort=8080')
82
print_line(' mcp start RpcUser=msf RpcPass=secret')
83
print_line
84
end
85
86
def cmd_mcp_tabs(str, words)
87
# words[0] is always 'mcp' (the command name itself)
88
# When words.length == 1, user is typing the subcommand
89
# When words.length >= 2, subcommand is words[1] and user is typing options
90
if words.length == 1
91
return SUBCOMMANDS.select { |s| s.start_with?(str.downcase) }
92
end
93
94
subcommand = words[1]
95
if %w[start restart].include?(subcommand)
96
VALID_OPTIONS.map { |opt| "#{opt}=" }.select { |o| o.downcase.start_with?(str.downcase) }
97
else
98
[]
99
end
100
end
101
102
private
103
104
def mcp_status
105
plugin.print_mcp_status
106
end
107
108
def mcp_start(args)
109
opts = parse_options(args)
110
return unless opts
111
112
plugin.start_server(opts)
113
end
114
115
def mcp_stop
116
plugin.stop_server
117
end
118
119
def mcp_restart(args)
120
opts = parse_options(args)
121
return unless opts
122
123
plugin.restart_server(opts)
124
end
125
126
# Parses Key=Value pairs from command arguments into an options hash.
127
# Option keys are case-insensitive and normalized to their canonical form.
128
# Returns nil and prints an error if any argument is malformed or unrecognized.
129
def parse_options(args)
130
opts = {}
131
args.each do |arg|
132
key, value = arg.split('=', 2)
133
unless key && value && !value.empty?
134
print_error("Invalid option format: #{arg} (expected Key=Value)")
135
return nil
136
end
137
canonical_key = VALID_OPTIONS.find { |opt| opt.casecmp(key).zero? }
138
unless canonical_key
139
print_error("Unknown option: #{key}")
140
print_error("Valid options: #{VALID_OPTIONS.join(', ')}")
141
return nil
142
end
143
opts[canonical_key] = value
144
end
145
opts
146
end
147
end
148
149
attr_accessor :auto_started_rpc, :mcp_server, :server_thread, :msf_client,
150
:rate_limiter, :server_config, :started_at
151
152
def initialize(framework, opts)
153
super
154
155
@server_config = nil
156
@auto_started_rpc = false
157
register_dispatcher
158
print_status("MCP plugin loaded. Use #{Msf::Ui::Tip.highlight('mcp start')} to start the server.")
159
end
160
161
#
162
# Returns 'mcp'
163
#
164
def name
165
'mcp'
166
end
167
168
#
169
# Returns the plugin description.
170
#
171
def desc
172
'Manages the Metasploit MCP server from within msfconsole'
173
end
174
175
#
176
# Cleans up resources when the plugin is unloaded.
177
#
178
def cleanup
179
if @mcp_server
180
stop_mcp_server
181
print_status('MCP server stopped')
182
end
183
deregister_dispatcher
184
unload_auto_started_rpc
185
super
186
end
187
188
#
189
# Public interface for the command dispatcher to control the server.
190
#
191
192
def print_mcp_status
193
unless @server_config
194
print_status('MCP server status: stopped (not configured)')
195
print_status(" Use #{Msf::Ui::Tip.highlight('mcp start')} to configure and start the server")
196
return
197
end
198
199
mcp_config = @server_config[:mcp]
200
201
if @mcp_server
202
print_status('MCP server status: running')
203
print_status(" Listening: http://#{Rex::Socket.to_authority(mcp_config[:host], mcp_config[:port])}")
204
print_status(" Uptime: #{format_uptime}")
205
else
206
print_status('MCP server status: stopped')
207
end
208
end
209
210
def start_server(opts = {})
211
if @mcp_server
212
print_error('MCP server is already running')
213
return
214
end
215
216
validate_options!(opts)
217
@server_config = resolve_config(opts)
218
@server_config[:rpc] = resolve_rpc_config(opts)
219
220
rpc = @server_config[:rpc]
221
start_mcp_server(rpc, @server_config)
222
rescue StandardError => e
223
# Ensure server is left in a clean stopped state on failure
224
stop_mcp_server
225
unload_auto_started_rpc
226
print_error("Failed to start MCP server: #{e.message}")
227
end
228
229
def stop_server
230
unless @mcp_server
231
print_error('MCP server is already stopped')
232
return
233
end
234
235
stop_mcp_server
236
print_status('MCP server stopped')
237
end
238
239
def restart_server(opts = {})
240
stop_mcp_server if @mcp_server
241
unload_auto_started_rpc
242
243
validate_options!(opts)
244
@server_config = resolve_config(opts)
245
@server_config[:rpc] = resolve_rpc_config(opts)
246
247
rpc = @server_config[:rpc]
248
start_mcp_server(rpc, @server_config)
249
rescue StandardError => e
250
# Ensure server is left in a clean stopped state on failure
251
stop_mcp_server
252
unload_auto_started_rpc
253
print_error("Failed to restart MCP server: #{e.message}")
254
end
255
256
private
257
258
def register_dispatcher
259
dispatcher = add_console_dispatcher(McpCommandDispatcher)
260
dispatcher.plugin = self
261
end
262
263
#
264
# Creates and starts the MCP server with the resolved configuration.
265
#
266
def start_mcp_server(rpc, config)
267
@msf_client = Msf::MCP::Metasploit::Client.new(
268
api_type: 'messagepack',
269
host: rpc[:host],
270
port: rpc[:port],
271
ssl: rpc[:ssl]
272
)
273
authenticate_with_retry(@msf_client, rpc[:user], rpc[:pass])
274
275
mcp_config = config[:mcp]
276
rate_limit = config[:rate_limit]
277
278
@rate_limiter = Msf::MCP::Security::RateLimiter.new(
279
requests_per_minute: rate_limit[:requests_per_minute]
280
)
281
282
@mcp_server = Msf::MCP::Server.new(
283
msf_client: @msf_client,
284
rate_limiter: @rate_limiter
285
)
286
287
host = mcp_config[:host]
288
port = mcp_config[:port]
289
290
@server_thread = framework.threads.spawn('MCPServer', false) do
291
@mcp_server.start(transport: :http, host: host, port: port)
292
end
293
294
@started_at = Time.now
295
print_server_status(mcp_config)
296
rescue Msf::MCP::Metasploit::AuthenticationError => e
297
raise Msf::MCP::Metasploit::AuthenticationError, "RPC authentication failed: #{e.message}"
298
rescue Msf::MCP::Metasploit::ConnectionError => e
299
raise Msf::MCP::Metasploit::ConnectionError, "RPC connection failed: #{e.message}"
300
rescue Errno::EADDRINUSE
301
raise Msf::MCP::Error, "Address already in use: #{Rex::Socket.to_authority(mcp_config[:host], mcp_config[:port])}"
302
end
303
304
def print_server_status(mcp_config)
305
print_status("MCP server started on #{Rex::Socket.to_authority(mcp_config[:host], mcp_config[:port])} (transport: http)")
306
end
307
308
def format_uptime
309
return 'N/A' unless @started_at
310
311
elapsed = (Time.now - @started_at).to_i
312
hours = elapsed / 3600
313
minutes = (elapsed % 3600) / 60
314
seconds = elapsed % 60
315
316
parts = []
317
parts << "#{hours}h" if hours > 0
318
parts << "#{minutes}m" if minutes > 0 || hours > 0
319
parts << "#{seconds}s"
320
parts.join(' ')
321
end
322
323
def stop_mcp_server
324
@mcp_server&.shutdown
325
terminate_server_thread
326
@mcp_server = nil
327
@server_thread = nil
328
@msf_client = nil
329
@rate_limiter = nil
330
@started_at = nil
331
end
332
333
# Retries RPC authentication to allow time for an auto-started msgrpc
334
# server to bind its port before we attempt to connect.
335
def authenticate_with_retry(client, user, pass, max_attempts: 10, delay: 0.5)
336
retries = @auto_started_rpc ? max_attempts : 1
337
attempts = 0
338
begin
339
attempts += 1
340
client.authenticate(user, pass)
341
rescue Msf::MCP::Metasploit::ConnectionError
342
if attempts < retries
343
sleep(delay)
344
retry
345
end
346
raise
347
end
348
end
349
350
# Waits for graceful thread exit, then force kills if necessary
351
def terminate_server_thread
352
return unless @server_thread&.alive?
353
354
unless @server_thread.join(5)
355
@server_thread.kill
356
print_warning('MCP server thread did not terminate gracefully, forced kill')
357
end
358
end
359
360
def deregister_dispatcher
361
remove_console_dispatcher('MCP')
362
rescue StandardError => e
363
print_warning("Failed to deregister MCP console dispatcher: #{e.message}")
364
end
365
366
def unload_auto_started_rpc
367
return unless @auto_started_rpc
368
369
begin
370
msgrpc = framework.plugins.find { |p| p.name == 'msgrpc' }
371
if msgrpc
372
# Give msgrpc server time to initialize before attempting unload
373
sleep(0.5) unless msgrpc.respond_to?(:server) && msgrpc.server
374
framework.plugins.unload(msgrpc)
375
end
376
rescue StandardError => e
377
print_warning("Failed to unload auto-started msgrpc: #{e.message}")
378
end
379
@auto_started_rpc = false
380
end
381
382
#
383
# Validates options before starting the server.
384
# Raises Msf::MCP::Config::ValidationError if any option value is invalid.
385
#
386
def validate_options!(opts)
387
validate_port_option!(opts, 'ServerPort')
388
validate_port_option!(opts, 'RpcPort')
389
validate_rpc_ssl_option!(opts)
390
validate_rate_limit_option!(opts)
391
validate_rpc_credentials!(opts)
392
end
393
394
def validate_port_option!(opts, key)
395
return unless opts[key]
396
397
port = Integer(opts[key], exception: false)
398
if port.nil? || port < 1 || port > 65_535
399
option_error(key, 'an integer between 1 and 65535')
400
end
401
end
402
403
def validate_rpc_ssl_option!(opts)
404
return unless opts['RpcSSL']
405
406
unless %w[true false].include?(opts['RpcSSL'])
407
option_error('RpcSSL', '"true" or "false"')
408
end
409
end
410
411
def validate_rate_limit_option!(opts)
412
return unless opts['RateLimit']
413
414
value = Integer(opts['RateLimit'], exception: false)
415
if value.nil? || value < 1 || value > 10_000
416
option_error('RateLimit', 'an integer between 1 and 10000')
417
end
418
end
419
420
def validate_rpc_credentials!(opts)
421
has_user = opts['RpcUser'] && !opts['RpcUser'].empty?
422
has_pass = opts['RpcPass'] && !opts['RpcPass'].empty?
423
has_host = opts['RpcHost'] && !opts['RpcHost'].empty?
424
425
if has_user && !has_pass
426
option_error('RpcPass', 'a value (both RpcUser and RpcPass are required)')
427
elsif has_pass && !has_user
428
option_error('RpcUser', 'a value (both RpcUser and RpcPass are required)')
429
elsif has_host && !has_pass
430
option_error('RpcPass', 'a value (RpcPass is required when connecting to a remote RPC host)')
431
end
432
end
433
434
#
435
# Translates validated plugin options into the internal configuration hash
436
# used by the MCP server components.
437
#
438
def resolve_config(opts)
439
mcp_config = {
440
transport: 'http',
441
host: opts['ServerHost'] || Msf::MCP::Config::Defaults::MCP_HOST,
442
port: Integer(opts['ServerPort'] || Msf::MCP::Config::Defaults::MCP_PORT)
443
}
444
445
rate_limit_value = Integer(opts['RateLimit'] || Msf::MCP::Config::Defaults::RATE_LIMIT_REQUESTS_PER_MINUTE)
446
447
{
448
mcp: mcp_config,
449
rate_limit: {
450
requests_per_minute: rate_limit_value,
451
burst_size: rate_limit_value
452
}
453
}
454
end
455
456
#
457
# Resolves RPC connection configuration using a priority-based approach:
458
# introspect loaded msgrpc (with explicit option overrides) > explicit only > auto-start msgrpc.
459
#
460
def resolve_rpc_config(opts)
461
@auto_started_rpc = false
462
463
if (msgrpc = find_loaded_msgrpc)
464
introspect_msgrpc(msgrpc, opts)
465
elsif explicit_rpc_credentials?(opts)
466
resolve_explicit_rpc(opts)
467
else
468
auto_start_msgrpc(opts)
469
end
470
end
471
472
def explicit_rpc_credentials?(opts)
473
(opts['RpcPass'] && !opts['RpcPass'].empty?) ||
474
(opts['RpcUser'] && !opts['RpcUser'].empty?) ||
475
(opts['RpcHost'] && !opts['RpcHost'].empty?)
476
end
477
478
# Explicit credentials provided — connect to external or local RPC
479
def resolve_explicit_rpc(opts)
480
{
481
host: opts['RpcHost'] || Msf::MCP::Config::Defaults::RPC_HOST,
482
port: Integer(opts['RpcPort'] || Msf::MCP::Config::Defaults::MSGRPC_PORT),
483
user: opts['RpcUser'] || Msf::MCP::Config::Defaults::RPC_USER,
484
pass: opts['RpcPass'],
485
ssl: (opts['RpcSSL'] || 'false') == 'true'
486
}
487
end
488
489
def find_loaded_msgrpc
490
framework.plugins.find { |p| p.name == 'msgrpc' }
491
end
492
493
# Extract connection details from a running msgrpc plugin instance
494
def introspect_msgrpc(plugin, opts)
495
server = plugin.server
496
user, pass = server.users.first
497
498
{
499
host: opts['RpcHost'] || server.srvhost,
500
port: Integer(opts['RpcPort'] || server.srvport),
501
user: opts['RpcUser'] || user,
502
pass: opts['RpcPass'] || pass,
503
ssl: resolve_ssl(opts, server)
504
}
505
end
506
507
def resolve_ssl(opts, server)
508
if opts['RpcSSL']
509
opts['RpcSSL'] == 'true'
510
else
511
server.options[:ssl] ? true : false
512
end
513
end
514
515
# No msgrpc loaded and no explicit creds — start one automatically
516
def auto_start_msgrpc(opts)
517
pass = Rex::Text.rand_text_alphanumeric(12)
518
user = Msf::MCP::Config::Defaults::RPC_USER
519
host = opts['RpcHost'] || Msf::MCP::Config::Defaults::RPC_HOST
520
port = opts['RpcPort'] || Msf::MCP::Config::Defaults::MSGRPC_PORT
521
ssl = opts['RpcSSL'] || 'false'
522
523
msgrpc_opts = {
524
'Pass' => pass,
525
'User' => user,
526
'ServerHost' => host,
527
'ServerPort' => port,
528
'SSL' => ssl
529
}
530
531
framework.plugins.load('msgrpc', msgrpc_opts)
532
@auto_started_rpc = true
533
534
print_status("Auto-started msgrpc - User: #{user}, Pass: #{pass}")
535
536
{
537
host: host,
538
port: Integer(port),
539
user: user,
540
pass: pass,
541
ssl: ssl == 'true'
542
}
543
end
544
545
def option_error(option_name, expected_format)
546
error_detail = "Invalid value for #{option_name}: expected #{expected_format}"
547
raise Msf::MCP::Config::ValidationError, { option_name => error_detail }
548
end
549
550
end
551
end
552
553