diff --git a/Gemfile b/Gemfile
index 1d7e16841b6a3..b30b30664af27 100644
--- a/Gemfile
+++ b/Gemfile
@@ -53,5 +53,7 @@ group :test do
gem 'allure-rspec'
# Manipulate Time.now in specs
gem 'timecop'
+ # stub and set expectations on HTTP requests
+ gem 'webmock', '~> 3.18'
end
diff --git a/Gemfile.lock b/Gemfile.lock
index 2b114786be28f..2b40468f5f23e 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -42,6 +42,7 @@ PATH
jsobfu
json
lru_redux
+ mcp (= 0.13.0)
metasm
metasploit-concern
metasploit-credential (>= 6.0.21)
@@ -223,6 +224,9 @@ GEM
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
cookiejar (0.3.4)
+ crack (1.0.1)
+ bigdecimal
+ rexml
crass (1.0.6)
csv (3.3.2)
daemons (1.4.1)
@@ -281,6 +285,7 @@ GEM
gyoku (1.4.0)
builder (>= 2.1.2)
rexml (~> 3.0)
+ hashdiff (1.2.1)
hashery (2.1.2)
hrr_rb_ssh (0.4.2)
hrr_rb_ssh-ed25519 (0.4.2)
@@ -304,6 +309,9 @@ GEM
jsobfu (0.4.2)
rkelly-remix
json (2.15.1)
+ json-schema (6.2.0)
+ addressable (~> 2.8)
+ bigdecimal (>= 3.1, < 5)
language_server-protocol (3.17.0.5)
license_finder (5.11.1)
bundler
@@ -322,6 +330,8 @@ GEM
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
lru_redux (1.1.0)
+ mcp (0.13.0)
+ json-schema (>= 4.1)
memory_profiler (1.1.0)
metasm (1.0.5)
metasploit-concern (5.0.5)
@@ -649,6 +659,10 @@ GEM
useragent (0.16.11)
warden (1.2.9)
rack (>= 2.0.9)
+ webmock (3.26.2)
+ addressable (>= 2.8.0)
+ crack (>= 0.3.2)
+ hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
websocket-driver (0.7.7)
base64
@@ -699,6 +713,7 @@ DEPENDENCIES
simplecov (= 0.18.2)
test-prof
timecop
+ webmock (~> 3.18)
yard
BUNDLED WITH
diff --git a/config/mcp_config.yaml.example b/config/mcp_config.yaml.example
new file mode 100644
index 0000000000000..435777fcb6b90
--- /dev/null
+++ b/config/mcp_config.yaml.example
@@ -0,0 +1,33 @@
+# Metasploit RPC API connection (MessagePack)
+msf_api:
+ type: messagepack
+ host: localhost
+ port: 55553
+ ssl: true
+ endpoint: /api/
+ user: msfuser
+ password: CHANGEME
+ auto_start_rpc: true # Automatically start the RPC server if not running (default: true)
+
+# MCP server configuration
+mcp:
+ transport: stdio # stdio (default) or http
+ # MCP server network configuration (for HTTP transport only)
+ host: localhost # Host to bind to (default: localhost)
+ port: 3000 # Port to listen on (default: 3000)
+
+# Rate limiting (optional - defaults shown)
+rate_limit:
+ enabled: true
+ requests_per_minute: 60
+ # If the `burst_size` is greater than `requests_per_minute`, a user will be allowed to exceed the rate limit temporarily.
+ # For example, with `requests_per_minute=5` and `burst_size=10`, a user could make 10 requests in a short period,
+ # but then would be limited to 5 requests per minute thereafter.
+ burst_size: 10
+
+# Logging (optional - defaults shown)
+logging:
+ enabled: false
+ level: INFO # DEBUG, INFO, WARN, ERROR
+ log_file: ~/.msf4/logs/msfmcp.log
+ sanitize: true
diff --git a/config/mcp_config_jsonrpc.yaml.example b/config/mcp_config_jsonrpc.yaml.example
new file mode 100644
index 0000000000000..12ca832304b56
--- /dev/null
+++ b/config/mcp_config_jsonrpc.yaml.example
@@ -0,0 +1,32 @@
+# Metasploit RPC API connection (JSON-RPC)
+msf_api:
+ type: json-rpc
+ host: localhost
+ port: 8081
+ ssl: true
+ endpoint: /api/v1/json-rpc
+ token: YOUR_BEARER_TOKEN_HERE
+ # auto_start_rpc is not supported for JSON-RPC (only MessagePack)
+
+# MCP server configuration
+mcp:
+ transport: stdio # stdio (default) or http
+ # MCP server network configuration (for HTTP transport only)
+ host: localhost # Host to bind to (default: localhost)
+ port: 3000 # Port to listen on (default: 3000)
+
+# Rate limiting (optional - defaults shown)
+rate_limit:
+ enabled: true
+ requests_per_minute: 60
+ # If the `burst_size` is greater than `requests_per_minute`, a user will be allowed to exceed the rate limit temporarily.
+ # For example, with `requests_per_minute=5` and `burst_size=10`, a user could make 10 requests in a short period,
+ # but then would be limited to 5 requests per minute thereafter.
+ burst_size: 10
+
+# Logging (optional - defaults shown)
+logging:
+ enabled: false
+ level: INFO # DEBUG, INFO, WARN, ERROR
+ log_file: ~/.msf4/logs/msfmcp.log
+ sanitize: true
diff --git a/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md b/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md
new file mode 100644
index 0000000000000..c46d7b671014c
--- /dev/null
+++ b/docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md
@@ -0,0 +1,366 @@
+The Metasploit MCP Server (`msfmcpd`) provides AI applications with secure, structured access to Metasploit Framework data through the [Model Context Protocol](https://modelcontextprotocol.io/) (MCP). It acts as a middleware layer between AI clients (such as Claude, Cursor, or custom agents) and Metasploit, exposing 8 standardized tools for querying reconnaissance data and searching modules.
+
+This initial implementation is **read-only**. Only tools that query data (modules, hosts, services, vulnerabilities, etc.) are available. Tools for module execution, session interaction, and database modifications will be added in a future iteration.
+
+## Architecture
+
+```mermaid
+flowchart TD
+ ai_app["AI Application
(Claude, Cursor, etc.)"]
+
+ subgraph msfmcp_server["MsfMcp Server"]
+ mcp_layer["MCP Layer (8 Tools)
Input Validation / Rate Limiting / Response Transformation"]
+ rpc_manager["RPC Manager
Auto-detect / Auto-start / Lifecycle Management"]
+ api_client["Metasploit API Client
MessagePack RPC (port 55553) / JSON-RPC (port 8081)
Session Management"]
+
+ mcp_layer --> rpc_manager
+ rpc_manager --> api_client
+ end
+
+ msf["Metasploit Framework
(msfrpcd)"]
+
+ ai_app -- "MCP Protocol (stdio or HTTP)
JSON-RPC 2.0" --> mcp_layer
+ api_client -- "HTTP/HTTPS" --> msf
+```
+
+## Quick Start
+
+The simplest way to start the MCP server is with no arguments:
+
+```
+./msfmcpd
+```
+
+The server automatically detects whether a Metasploit RPC server is already running on the configured port. If not, it starts one automatically with randomly generated credentials.
+
+To use specific credentials:
+
+```
+./msfmcpd --user your_username --password your_password
+```
+
+## Configuration
+
+### Configuration File
+
+Copy the example configuration and edit it:
+
+```
+cp config/mcp_config.yaml.example config/mcp_config.yaml
+```
+
+A MessagePack RPC configuration looks like this:
+
+```yaml
+msf_api:
+ type: messagepack
+ host: localhost
+ port: 55553
+ ssl: true
+ endpoint: /api/
+ user: msfuser
+ password: CHANGEME
+ auto_start_rpc: true
+
+mcp:
+ transport: stdio
+
+rate_limit:
+ enabled: true
+ requests_per_minute: 60
+ burst_size: 10
+
+logging:
+ enabled: false
+ level: INFO
+ log_file: msfmcp.log
+```
+
+For JSON-RPC with bearer token authentication, use the JSON-RPC example instead:
+
+```
+cp config/mcp_config_jsonrpc.yaml.example config/mcp_config.yaml
+```
+
+### Command-Line Options
+
+```
+./msfmcpd --help
+
+Options:
+ --config PATH Path to configuration file
+ --enable-logging Enable file logging with sanitization
+ --log-file PATH Log file path (overrides config file)
+ --user USER MSF API username (for MessagePack auth)
+ --password PASS MSF API password (for MessagePack auth)
+ --no-auto-start-rpc Disable automatic RPC server startup
+ --mcp-transport TRANSPORT MCP server transport type ('stdio' or 'http')
+ -h, --help Show this help message
+ -v, --version Show version information
+```
+
+### Environment Variable Overrides
+
+All configuration settings can be overridden by environment variables:
+
+| Variable | Description |
+|---|---|
+| `MSF_API_TYPE` | Connection type (`messagepack` or `json-rpc`) |
+| `MSF_API_HOST` | Metasploit RPC API host |
+| `MSF_API_PORT` | Metasploit RPC API port |
+| `MSF_API_SSL` | Use SSL for Metasploit RPC API (`true` or `false`) |
+| `MSF_API_ENDPOINT` | Metasploit RPC API endpoint |
+| `MSF_API_USER` | RPC API username (for MessagePack auth) |
+| `MSF_API_PASSWORD` | RPC API password (for MessagePack auth) |
+| `MSF_API_TOKEN` | RPC API token (for JSON-RPC auth) |
+| `MSF_AUTO_START_RPC` | Auto-start RPC server (`true` or `false`) |
+| `MSF_MCP_TRANSPORT` | MCP transport type (`stdio` or `http`) |
+| `MSF_MCP_HOST` | MCP server host (for HTTP transport) |
+| `MSF_MCP_PORT` | MCP server port (for HTTP transport) |
+
+Example using environment variables:
+
+```
+MSF_API_HOST=192.168.33.44 ./msfmcpd --config ./config/mcp_config.yaml
+```
+
+## Automatic RPC Server Management
+
+When using MessagePack RPC on localhost, the MCP server can automatically manage the Metasploit RPC server lifecycle. This is enabled by default.
+
+### How It Works
+
+1. **Detection**: On startup, the MCP server probes the configured RPC port to check if a server is already running.
+2. **Auto-start**: If no server is detected, it spawns the `msfrpcd` executable as a child process.
+3. **Credentials**: If no username and password are provided, random credentials are generated automatically and used for both the RPC server and client authentication.
+4. **Wait**: After starting, it polls the port until the RPC server becomes available (timeout: 30 seconds).
+5. **Shutdown**: When the MCP server shuts down (via Ctrl+C or SIGTERM), it cleans up the managed RPC process.
+
+**Note**: If an RPC server is already running, credentials must be provided via `--user`/`--password`, config file, or environment variables to authenticate with it.
+
+### Database Support
+
+The auto-started RPC server creates a framework instance with database support enabled by default. If the database is not running when the RPC server starts, a warning is displayed:
+
+```
+[WARNING] Database is not available. Some MCP tools that rely on the database will not work.
+[WARNING] Start the database and restart the MCP server to enable full functionality.
+```
+
+Tools that query the database (`msf_host_info`, `msf_service_info`, `msf_vulnerability_info`, `msf_note_info`, `msf_credential_info`, `msf_loot_info`) require a running database. To initialize and start the database:
+
+```
+msfdb init
+msfdb start
+```
+
+Then restart the MCP server.
+
+### Disabling Auto-Start
+
+Auto-start can be disabled in three ways:
+
+- CLI flag: `--no-auto-start-rpc`
+- Config file: `auto_start_rpc: false` in the `msf_api` section
+- Environment variable: `MSF_AUTO_START_RPC=false`
+
+Auto-start is also not available when:
+
+- The API type is `json-rpc` (requires SSL certificates and a web server)
+- The host is a remote address (cannot start a server on a remote machine)
+
+When auto-start is disabled and no RPC server is running, you must start `msfrpcd` manually:
+
+```
+msfrpcd -U your_username -P your_password -p 55553
+```
+
+## MCP Tools
+
+The server exposes 8 tools to AI applications via the MCP protocol.
+
+### msf_search_modules
+
+Search for Metasploit modules by keywords, CVE IDs, or module names.
+
+- `query` (string, required): Search terms (e.g., `windows smb`, `CVE-2017-0144`)
+- `limit` (integer, optional): Max results (1-1000, default: 100)
+- `offset` (integer, optional): Pagination offset (default: 0)
+
+### msf_module_info
+
+Get detailed information about a specific Metasploit module.
+
+- `type` (string, required): Module type (`exploit`, `auxiliary`, `post`, `payload`, `encoder`, `nop`)
+- `name` (string, required): Module path (e.g., `windows/smb/ms17_010_eternalblue`)
+
+Returns complete module details including options, targets, references, and authors.
+
+### msf_host_info
+
+Query discovered hosts from the Metasploit database.
+
+- `workspace` (string, optional): Workspace name (default: `default`)
+- `addresses` (string, optional): Filter by IP/CIDR (e.g., `192.168.1.0/24`)
+- `only_up` (boolean, optional): Only return alive hosts (default: false)
+- `limit` (integer, optional): Max results (1-1000, default: 100)
+- `offset` (integer, optional): Pagination offset (default: 0)
+
+### msf_service_info
+
+Query discovered services on hosts.
+
+- `workspace` (string, optional): Workspace name
+- `names` (string, optional): Filter by service names, comma-separated (e.g., `http`, `ldap,ssh`)
+- `host` (string, optional): Filter by host IP
+- `ports` (string, optional): Filter by port or range (e.g., `80,443` or `1-1024`)
+- `protocol` (string, optional): Protocol filter (`tcp` or `udp`)
+- `only_up` (boolean, optional): Only return running services (default: false)
+- `limit` (integer, optional): Max results (1-1000, default: 100)
+- `offset` (integer, optional): Pagination offset (default: 0)
+
+### msf_vulnerability_info
+
+Query discovered vulnerabilities.
+
+- `workspace` (string, optional): Workspace name
+- `names` (array of strings, optional): Filter by vulnerability names (exact, case-sensitive module names)
+- `host` (string, optional): Filter by host IP
+- `ports` (string, optional): Filter by port or range
+- `protocol` (string, optional): Protocol filter (`tcp` or `udp`)
+- `limit` (integer, optional): Max results (1-1000, default: 100)
+- `offset` (integer, optional): Pagination offset (default: 0)
+
+### msf_note_info
+
+Query notes stored in the database.
+
+- `workspace` (string, optional): Workspace name
+- `type` (string, optional): Filter by note type (e.g., `ssl.certificate`, `smb.fingerprint`)
+- `host` (string, optional): Filter by host IP
+- `ports` (string, optional): Filter by port or range
+- `protocol` (string, optional): Protocol filter (`tcp` or `udp`)
+- `limit` (integer, optional): Max results (1-1000, default: 100)
+- `offset` (integer, optional): Pagination offset (default: 0)
+
+### msf_credential_info
+
+Query discovered credentials.
+
+- `workspace` (string, optional): Workspace name
+- `limit` (integer, optional): Max results (1-1000, default: 100)
+- `offset` (integer, optional): Pagination offset (default: 0)
+
+### msf_loot_info
+
+Query collected loot (files, data dumps).
+
+- `workspace` (string, optional): Workspace name
+- `limit` (integer, optional): Max results (1-1000, default: 100)
+- `offset` (integer, optional): Pagination offset (default: 0)
+
+## Integration with AI Applications
+
+Add the MCP server to your AI application configuration. The exact format depends on the client.
+
+### Claude Desktop / Cursor
+
+```json
+{
+ "mcpServers": {
+ "metasploit": {
+ "command": "/path/to/metasploit-framework/msfmcpd",
+ "args": [
+ "--config",
+ "/path/to/config/mcp_config.yaml"
+ ],
+ "env": {}
+ }
+ }
+}
+```
+
+### Using RVM
+
+If you use RVM to manage Ruby versions, specify the full path to RVM so the correct Ruby and gemset are used:
+
+```json
+{
+ "mcpServers": {
+ "metasploit": {
+ "command": "/your/home_dir/.rvm/bin/rvm",
+ "args": [
+ "in",
+ "/path/to/metasploit-framework",
+ "do",
+ "./msfmcpd",
+ "--config",
+ "config/mcp_config.yaml"
+ ]
+ }
+ }
+}
+```
+
+## Security Considerations
+
+### Input Validation
+
+All tool parameters are validated against strict JSON schemas. IP addresses are validated using Ruby's `IPAddr` class with CIDR support, workspace names are restricted to alphanumeric characters plus underscore/hyphen, port ranges are validated (1-65535), and search queries are limited to 500 characters.
+
+### Credential Management
+
+Configuration files should use `chmod 600` permissions. Credentials are transmitted securely to the Metasploit Framework API and are never cached or logged by the MCP server.
+
+### Rate Limiting
+
+The server applies rate limiting to all MCP tools using a token bucket algorithm. Default: 60 requests per minute with a burst of 10 requests. This is configurable in the `rate_limit` section of the configuration file.
+
+### Logging
+
+Logging is disabled by default. When enabled (via `--enable-logging` or config), sensitive data (passwords, tokens, API keys) is automatically redacted. Log files should be protected with `chmod 600`.
+
+### Error Handling
+
+Stack traces are never exposed to clients. Error messages are sanitized to avoid leaking credentials. Metasploit API errors are wrapped in the MCP error format.
+
+## Testing with MCP Inspector
+
+The [MCP Inspector](https://github.com/modelcontextprotocol/inspector) is an interactive developer tool for testing and debugging MCP servers. It runs directly through `npx`:
+
+```
+npx @modelcontextprotocol/inspector
+```
+
+## Troubleshooting
+
+### Connection Refused or Timeout
+
+1. Verify the RPC daemon is running: `ps aux | grep msfrpcd`
+2. Check the port is listening: `netstat -an | grep 55553`
+3. Test connectivity: `curl -k -v https://localhost:55553/api/`
+
+### Authentication Failures
+
+For MessagePack RPC, verify the username and password in your configuration file or CLI arguments. For JSON-RPC, verify the bearer token is valid and has not expired.
+
+### Database Not Available
+
+If database-dependent tools return errors, ensure the database is running:
+
+```
+msfdb init
+msfdb start
+```
+
+Then restart the MCP server.
+
+### Rate Limit Exceeded
+
+Increase the rate limit in your configuration file:
+
+```yaml
+rate_limit:
+ requests_per_minute: 120
+ burst_size: 20
+```
diff --git a/docs/navigation.rb b/docs/navigation.rb
index 5364e3f527fff..d9e049ab983f6 100644
--- a/docs/navigation.rb
+++ b/docs/navigation.rb
@@ -448,6 +448,9 @@ def without_prefix(prefix)
{
path: 'How-to-use-Metasploit-with-ngrok.md'
},
+ {
+ path: 'How-to-use-Metasploit-MCP-Server.md'
+ },
]
},
]
diff --git a/lib/msf/core/mcp.rb b/lib/msf/core/mcp.rb
new file mode 100644
index 0000000000000..b7cca12f594f4
--- /dev/null
+++ b/lib/msf/core/mcp.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+# Main entry point for MSF MCP Server
+module Msf
+ module MCP
+ VERSION = '0.1.0'
+ end
+end
+
+# Load the base configuration (for default paths, etc.)
+require 'msf/base/config'
+
+# Load the base Rex libraries
+require 'rex/socket'
+require 'rex/logging'
+require 'rex/logging/log_sink'
+
+module Msf
+ module MCP
+ # Log source identifier for all MCP log messages.
+ LOG_SOURCE = 'mcp'
+
+ # Log level aliases — semantic names for Rex::Logging level constants.
+ LOG_DEBUG = Rex::Logging::LEV_3
+ LOG_INFO = Rex::Logging::LEV_2
+ LOG_WARN = Rex::Logging::LEV_1
+ LOG_ERROR = Rex::Logging::LEV_0
+ end
+end
+
+# Load the MCP-specific logging components
+require_relative 'mcp/logging/sinks/json_stream'
+require_relative 'mcp/logging/sinks/json_flatfile'
+require_relative 'mcp/logging/sinks/sanitizing'
+require_relative 'mcp/middleware/request_logger'
+
+# Error classes
+require_relative 'mcp/errors'
+
+# Configuration Layer
+require_relative 'mcp/config/loader'
+require_relative 'mcp/config/validator'
+
+# Security Layer
+require_relative 'mcp/security/input_validator'
+require_relative 'mcp/security/rate_limiter'
+
+# Metasploit Client Layer
+require_relative 'mcp/rpc_manager'
+require_relative 'mcp/metasploit/messagepack_client'
+require_relative 'mcp/metasploit/jsonrpc_client'
+require_relative 'mcp/metasploit/client'
+require_relative 'mcp/metasploit/response_transformer'
+
+# MCP SDK
+require 'mcp'
+
+# MCP Layer
+require_relative 'mcp/tools/tool_helper'
+require_relative 'mcp/tools/search_modules'
+require_relative 'mcp/tools/module_info'
+require_relative 'mcp/tools/host_info'
+require_relative 'mcp/tools/service_info'
+require_relative 'mcp/tools/vulnerability_info'
+require_relative 'mcp/tools/note_info'
+require_relative 'mcp/tools/credential_info'
+require_relative 'mcp/tools/loot_info'
+require_relative 'mcp/server'
+
+# Application Layer
+require_relative 'mcp/application'
+
+# Make logging stubs (ilog, elog, dlog, wlog)
+include Rex::Logging
+
diff --git a/lib/msf/core/mcp/application.rb b/lib/msf/core/mcp/application.rb
new file mode 100644
index 0000000000000..8d1ba4efe5a31
--- /dev/null
+++ b/lib/msf/core/mcp/application.rb
@@ -0,0 +1,334 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'optparse'
+
+module Msf::MCP
+ # Main application class that orchestrates the MCP server startup and lifecycle
+ class Application
+ VERSION = '0.1.0'
+ BANNER = <<~BANNER
+ MSF MCP Server v#{VERSION}
+ Model Context Protocol server for Metasploit Framework
+ BANNER
+
+ # For testing purposes:
+ attr_reader :config, :msf_client, :mcp_server, :rate_limiter, :options, :rpc_manager
+
+ # Initialize the application with command-line arguments
+ #
+ # @param argv [Array] Command-line arguments
+ # @param output [IO] Output stream for messages (default: $stderr)
+ def initialize(argv = ARGV, output: $stderr)
+ @argv = argv.dup
+ @output = output
+ @options = {}
+ @config = nil
+ @msf_client = nil
+ @mcp_server = nil
+ @rate_limiter = nil
+ @rpc_manager = nil
+ end
+
+ # Run the application
+ #
+ # @return [void]
+ def run
+ parse_arguments
+ install_signal_handlers
+ load_configuration
+ validate_configuration
+ initialize_logger
+ initialize_rate_limiter
+ ensure_rpc_server
+ initialize_metasploit_client
+ authenticate_metasploit
+ initialize_mcp_server
+ start_mcp_server
+ rescue Msf::MCP::Config::ValidationError, Msf::MCP::Config::ConfigurationError => e
+ handle_configuration_error(e)
+ rescue Msf::MCP::Metasploit::ConnectionError => e
+ handle_connection_error(e)
+ rescue Msf::MCP::Metasploit::APIError => e
+ handle_api_error(e)
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ handle_authentication_error(e)
+ rescue Msf::MCP::Metasploit::RpcStartupError => e
+ handle_rpc_startup_error(e)
+ rescue StandardError => e
+ handle_fatal_error(e)
+ end
+
+ # Shutdown the application gracefully
+ #
+ # Performs cleanup operations before process termination:
+ # - Logs shutdown event via Rex
+ # - Closes MCP server and Metasploit client connections
+ # - Cleans up resources
+ #
+ # @param signal [String] Signal name (e.g., 'INT', 'TERM')
+ # @return [void]
+ def shutdown(signal = 'INT')
+ ilog({
+ message: 'Shutting down',
+ context: { signal: "SIG#{signal}" }
+ }, LOG_SOURCE, LOG_INFO)
+ @mcp_server&.shutdown
+ @rpc_manager&.stop_rpc_server
+ @output.puts "\nShutdown complete"
+ end
+
+ private
+
+ # Parse command-line arguments
+ #
+ # @return [void]
+ def parse_arguments
+ parser = OptionParser.new do |opts|
+ opts.banner = BANNER + "\nUsage: msfmcp [options]"
+
+ opts.on('--config PATH', 'Path to configuration file') do |path|
+ @options[:config_path] = File.expand_path(path)
+ end
+
+ opts.on('--enable-logging', 'Enable file logging') do
+ @options[:enable_logging_cli] = true
+ end
+
+ opts.on('--log-file PATH', 'Log file path (overrides config file)') do |path|
+ @options[:log_file_cli] = path
+ end
+
+ opts.on('--user USER', 'MSF API username (for MessagePack auth)') do |user|
+ @options[:msf_user_cli] = user
+ end
+
+ opts.on('--password PASS', 'MSF API password (for MessagePack auth)') do |password|
+ @options[:msf_password_cli] = password
+ end
+
+ opts.on('--no-auto-start-rpc', 'Disable automatic RPC server startup') do
+ @options[:no_auto_start_rpc] = true
+ end
+
+ opts.on('--mcp-transport TRANSPORT', 'MCP server transport type (\'stdio\' or \'http\')') do |transport|
+ @options[:mcp_transport] = transport
+ end
+
+ opts.on('-h', '--help', 'Show this help message') do
+ @output.puts opts
+ exit 0
+ end
+
+ opts.on('-v', '--version', 'Show version information') do
+ @output.puts "msfmcp version #{VERSION}"
+ exit 0
+ end
+ end
+
+ parser.parse!(@argv)
+ end
+
+ # Register a Rex log source when logging is enabled.
+ #
+ # Selects a JsonFlatfile sink pointed at the configured log path and wraps it
+ # with the sanitizing middleware unless sanitization has been explicitly
+ # disabled in the config.
+ #
+ # Priority: CLI flags > config file > defaults
+ #
+ # @return [void]
+ def initialize_logger
+ return unless @options[:enable_logging_cli] || @config.dig(:logging, :enabled)
+
+ log_file = @options[:log_file_cli] || @config.dig(:logging, :log_file)
+ level = @config.dig(:logging, :level)
+ threshold = case @config.dig(:logging, :level).upcase
+ when 'DEBUG'
+ Rex::Logging::LEV_3
+ when 'INFO'
+ Rex::Logging::LEV_2
+ when 'WARN'
+ Rex::Logging::LEV_1
+ when 'ERROR'
+ Rex::Logging::LEV_0
+ end
+ inner = Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file)
+ sink = @config.dig(:logging, :sanitize) ? Msf::MCP::Logging::Sinks::Sanitizing.new(inner) : inner
+
+ deregister_log_source(LOG_SOURCE) if log_source_registered?(LOG_SOURCE)
+ register_log_source(LOG_SOURCE, sink, threshold)
+ end
+
+ # Install signal handlers for graceful shutdown
+ #
+ # @return [void]
+ def install_signal_handlers
+ Signal.trap('INT') { shutdown('INT'); exit 0 }
+ Signal.trap('TERM') { shutdown('TERM'); exit 0 }
+ end
+
+ # Load configuration from file or use defaults
+ #
+ # @return [void]
+ def load_configuration
+ if @options[:config_path]
+ @output.puts "Loading configuration from #{@options[:config_path]}"
+ @config = Msf::MCP::Config::Loader.load(@options[:config_path])
+ else
+ @output.puts "No configuration file specified, using defaults"
+ @config = Msf::MCP::Config::Loader.load_from_hash({})
+ end
+
+ # Apply CLI authentication overrides (highest priority)
+ if @options[:msf_user_cli]
+ @config[:msf_api][:user] = @options[:msf_user_cli]
+ end
+ if @options[:msf_password_cli]
+ @config[:msf_api][:password] = @options[:msf_password_cli]
+ end
+ if @options[:no_auto_start_rpc]
+ @config[:msf_api][:auto_start_rpc] = false
+ end
+ if @options[:mcp_transport]
+ @config[:mcp][:transport] = @options[:mcp_transport]
+ end
+ end
+
+ # Validate the loaded configuration
+ #
+ # @return [void]
+ def validate_configuration
+ @output.puts "Validating configuration..."
+ Msf::MCP::Config::Validator.validate!(@config)
+ @output.puts "Configuration valid"
+ end
+
+ # Initialize the rate limiter
+ #
+ # @return [void]
+ def initialize_rate_limiter
+ @rate_limiter = Msf::MCP::Security::RateLimiter.new(
+ requests_per_minute: @config.dig(:rate_limit, :requests_per_minute) || 60,
+ burst_size: @config.dig(:rate_limit, :burst_size)
+ )
+ end
+
+ # Ensure the Metasploit RPC server is available, auto-starting if needed
+ #
+ # @return [void]
+ def ensure_rpc_server
+ @rpc_manager = Msf::MCP::RpcManager.new(
+ config: @config,
+ output: @output
+ )
+ @rpc_manager.ensure_rpc_available
+ end
+
+ # Initialize the Metasploit client
+ #
+ # @return [void]
+ def initialize_metasploit_client
+ @output.puts "Connecting to Metasploit RPC at #{@config[:msf_api][:host]}:#{@config[:msf_api][:port]}"
+ @msf_client = Msf::MCP::Metasploit::Client.new(
+ api_type: @config[:msf_api][:type],
+ host: @config[:msf_api][:host],
+ port: @config[:msf_api][:port],
+ endpoint: @config[:msf_api][:endpoint],
+ token: @config[:msf_api][:token],
+ ssl: @config[:msf_api][:ssl]
+ )
+ end
+
+ # Authenticate with Metasploit if using MessagePack
+ #
+ # @return [void]
+ def authenticate_metasploit
+ if @config[:msf_api][:type] == 'messagepack'
+ @output.puts "Authenticating with Metasploit..."
+ @msf_client.authenticate(@config[:msf_api][:user].to_s, @config[:msf_api][:password].to_s)
+ @output.puts "Authentication successful"
+ else
+ @output.puts "Using JSON-RPC with token authentication"
+ end
+ end
+
+ # Initialize the MCP server
+ #
+ # @return [void]
+ def initialize_mcp_server
+ @output.puts "Initializing MCP server..."
+ @mcp_server = Msf::MCP::Server.new(
+ msf_client: @msf_client,
+ rate_limiter: @rate_limiter
+ )
+ end
+
+ # Start the MCP server with configured transport
+ #
+ # @return [void]
+ def start_mcp_server
+ transport = (@config.dig(:mcp, :transport) || 'stdio').to_sym
+ host = @config.dig(:mcp, :host) || 'localhost'
+ port = @config.dig(:mcp, :port) || 3000
+
+ if transport == :http
+ @output.puts "Starting MCP server on HTTP transport..."
+ @output.puts "Server listening on http://#{host}:#{port}"
+ @output.puts "Press Ctrl+C to shutdown"
+ @mcp_server.start(transport: :http, host: host, port: port)
+ else
+ @output.puts "Starting MCP server on stdio transport..."
+ @output.puts "Server ready - waiting for MCP requests"
+ @output.puts "Press Ctrl+C to shutdown"
+ @mcp_server.start(transport: :stdio)
+ end
+ end
+
+ # Error handlers
+
+ def handle_configuration_error(error)
+ @output.puts "Configuration validation failed: #{error.message}"
+ exit 1
+ end
+
+ def handle_connection_error(error)
+ elog({
+ message: 'Connection error',
+ context: { host: @config[:msf_api][:host], port: @config[:msf_api][:port] },
+ exception: error
+ }, LOG_SOURCE, LOG_ERROR)
+ @output.puts "Connection error to Metasploit RPC at #{@config[:msf_api][:host]}:#{@config[:msf_api][:port]} - #{error.message}"
+ exit 1
+ end
+
+ def handle_api_error(error)
+ elog({ message: 'Metasploit API error', exception: error }, LOG_SOURCE, LOG_ERROR)
+ @output.puts "Metasploit API error: #{error.message}"
+ exit 1
+ end
+
+ def handle_authentication_error(error)
+ elog({
+ message: 'Authentication error',
+ context: { username: @config[:msf_api][:user].to_s },
+ exception: error
+ }, LOG_SOURCE, LOG_ERROR)
+ @output.puts "Authentication error (username: #{@config[:msf_api][:user]}): #{error.message}"
+ exit 1
+ end
+
+ def handle_rpc_startup_error(error)
+ elog({ message: 'RPC startup error', exception: error }, LOG_SOURCE, LOG_ERROR)
+ @output.puts "RPC startup error: #{error.message}"
+ exit 1
+ end
+
+ def handle_fatal_error(error)
+ elog({ message: 'Fatal error during startup', exception: error }, LOG_SOURCE, LOG_ERROR)
+ @output.puts "Fatal error: #{error.message}"
+ @output.puts error.backtrace.first(5).join("\n") if error.backtrace
+ exit 1
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/config/loader.rb b/lib/msf/core/mcp/config/loader.rb
new file mode 100644
index 0000000000000..75f9cf39e3b7e
--- /dev/null
+++ b/lib/msf/core/mcp/config/loader.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+require 'yaml'
+
+module Msf::MCP
+ module Config
+ class Loader
+ # Load configuration from YAML file with environment variable overrides
+ #
+ # @param file_path [String] Path to YAML configuration file
+ # @return [Hash] Configuration hash with symbolized keys
+ # @raise [ConfigurationError] If file not found or invalid YAML
+ def self.load(file_path)
+ unless File.exist?(file_path)
+ raise ConfigurationError, "Configuration file not found: #{file_path}"
+ end
+
+ begin
+ config = YAML.safe_load_file(file_path, symbolize_names: true)
+ rescue Psych::SyntaxError => e
+ raise ConfigurationError, "Invalid YAML syntax in #{file_path}: #{e.message}"
+ end
+
+ unless config.is_a?(Hash)
+ raise ConfigurationError, "Configuration file must contain a YAML hash/dictionary"
+ end
+
+ apply_defaults(config)
+ apply_env_overrides(config)
+ config
+ end
+
+ # Load configuration from hash (for testing)
+ #
+ # @param config_hash [Hash] Configuration hash
+ # @return [Hash] Configuration hash with defaults and env overrides
+ def self.load_from_hash(config_hash)
+ config = config_hash.dup
+ apply_defaults(config)
+ apply_env_overrides(config)
+ config
+ end
+
+
+ private
+
+ # Apply default values to configuration
+ #
+ # @param config [Hash] Configuration hash to modify in place
+ def self.apply_defaults(config)
+ config[:msf_api] ||= {}
+ config[:mcp] ||= {}
+ config[:rate_limit] ||= {}
+ config[:logging] ||= {}
+
+ config[:msf_api][:type] ||= 'messagepack'
+ config[:msf_api][:host] ||= 'localhost'
+ config[:msf_api][:port] ||= (config[:msf_api][:type] == 'json-rpc') ? 8081 : 55553
+
+ config[:msf_api][:ssl] = config[:msf_api].fetch(:ssl, true)
+ config[:msf_api][:auto_start_rpc] = config[:msf_api].fetch(:auto_start_rpc, true)
+
+ config[:msf_api][:endpoint] ||= case config[:msf_api][:type]
+ when 'json-rpc'
+ Msf::MCP::Metasploit::JsonRpcClient::DEFAULT_ENDPOINT
+ else
+ Msf::MCP::Metasploit::MessagePackClient::DEFAULT_ENDPOINT
+ end
+
+ config[:mcp][:transport] ||= 'stdio'
+
+ if config[:mcp][:transport] == 'http'
+ config[:mcp][:host] ||= 'localhost'
+ config[:mcp][:port] ||= 3000
+ end
+
+ config[:rate_limit][:enabled] = config[:rate_limit].fetch(:enabled, true)
+ config[:rate_limit][:requests_per_minute] ||= 60
+ config[:rate_limit][:burst_size] ||= 10
+
+ config[:logging][:enabled] = config[:logging].fetch(:enabled, false)
+ config[:logging][:level] ||= 'INFO'
+ config[:logging][:log_file] ||= File.join(Msf::Config.log_directory, 'msfmcp.log')
+ config[:logging][:sanitize] = config[:logging].fetch(:sanitize, true)
+ end
+
+ # Apply environment variable overrides
+ #
+ # @param config [Hash] Configuration hash to modify in place
+ def self.apply_env_overrides(config)
+ # Ensure nested hashes exist
+ config[:msf_api] ||= {}
+ config[:mcp] ||= {}
+
+ # MSF API overrides
+ config[:msf_api][:type] = ENV['MSF_API_TYPE'] if ENV['MSF_API_TYPE']
+ config[:msf_api][:host] = ENV['MSF_API_HOST'] if ENV['MSF_API_HOST']
+ config[:msf_api][:port] = ENV['MSF_API_PORT'].to_i if ENV['MSF_API_PORT']
+ config[:msf_api][:ssl] = parse_boolean(ENV['MSF_API_SSL']) if ENV['MSF_API_SSL'] && !ENV['MSF_API_SSL'].empty?
+ config[:msf_api][:endpoint] = ENV['MSF_API_ENDPOINT'] if ENV['MSF_API_ENDPOINT']
+ config[:msf_api][:user] = ENV['MSF_API_USER'] if ENV['MSF_API_USER']
+ config[:msf_api][:password] = ENV['MSF_API_PASSWORD'] if ENV['MSF_API_PASSWORD']
+ config[:msf_api][:token] = ENV['MSF_API_TOKEN'] if ENV['MSF_API_TOKEN']
+ config[:msf_api][:auto_start_rpc] = parse_boolean(ENV['MSF_AUTO_START_RPC']) if ENV['MSF_AUTO_START_RPC']
+
+ # MCP transport override
+ config[:mcp][:transport] = ENV['MSF_MCP_TRANSPORT'] if ENV['MSF_MCP_TRANSPORT']
+
+ # MCP server network overrides
+ config[:mcp][:host] = ENV['MSF_MCP_HOST'] if ENV['MSF_MCP_HOST']
+ config[:mcp][:port] = ENV['MSF_MCP_PORT'].to_i if ENV['MSF_MCP_PORT']
+ end
+
+ # Parse a string value into a boolean
+ #
+ # @param value [String] String to parse ('true', '1', 'yes' → true; anything else → false)
+ # @return [Boolean]
+ def self.parse_boolean(value)
+ %w[true 1 yes].include?(value.to_s.downcase)
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/config/validator.rb b/lib/msf/core/mcp/config/validator.rb
new file mode 100644
index 0000000000000..176c71ce4ef87
--- /dev/null
+++ b/lib/msf/core/mcp/config/validator.rb
@@ -0,0 +1,202 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Config
+ class Validator
+ VALID_API_TYPES = %w[messagepack json-rpc].freeze
+ VALID_TRANSPORTS = %w[stdio http].freeze
+
+ # Validate configuration hash (class method)
+ #
+ # @param config [Hash] Configuration hash to validate
+ # @return [true] If validation passes
+ # @raise [ValidationError] If validation fails
+ def self.validate!(config)
+ new.validate!(config)
+ end
+
+ # Validate configuration hash (instance method)
+ #
+ # @param config [Hash] Configuration hash to validate
+ # @return [true] If validation passes
+ # @raise [ValidationError] If validation fails
+ def validate!(config)
+ errors = {}
+
+ # Check msf_api section exists
+ unless config[:msf_api].is_a?(Hash)
+ errors[:msf_api] = "configuration section is required"
+ raise ValidationError.new(errors)
+ end
+
+ # Validate API type
+ if config[:msf_api][:type] && !VALID_API_TYPES.include?(config[:msf_api][:type])
+ errors[:'msf_api.type'] = "must be one of the valid API types: #{VALID_API_TYPES.join(', ')}"
+ end
+
+ # Validate API type
+ if config[:msf_api][:host] && config[:msf_api][:host].to_s.strip.empty?
+ errors[:'msf_api.host'] = "must be a non-empty string"
+ end
+
+ # Validate mcp section type
+ if config.key?(:mcp) && !config[:mcp].is_a?(Hash)
+ errors[:mcp] = "must be a configuration hash"
+ end
+
+ # Validate transport
+ if config[:mcp].is_a?(Hash) && config[:mcp][:transport] && !VALID_TRANSPORTS.include?(config[:mcp][:transport])
+ errors[:'mcp.transport'] = "must be one of the valid transport: #{VALID_TRANSPORTS.join(', ')}"
+ end
+
+ # Validate port
+ if config[:msf_api][:port]
+ port = config[:msf_api][:port].to_i
+ unless port.between?(1, 65535)
+ errors[:'msf_api.port'] = "must be between 1 and 65535"
+ end
+ end
+
+ # Validate SSL option
+ if config[:msf_api].key?(:ssl) && ![true, false].include?(config[:msf_api][:ssl])
+ errors[:'msf_api.ssl'] = "must be boolean (true or false)"
+ end
+
+ # Validate auto_start_rpc option
+ if config[:msf_api].key?(:auto_start_rpc) && ![true, false].include?(config[:msf_api][:auto_start_rpc])
+ errors[:'msf_api.auto_start_rpc'] = "must be boolean (true or false)"
+ end
+
+ # Validate MCP port
+ if config[:mcp].is_a?(Hash) && config[:mcp][:port]
+ port = config[:mcp][:port].to_i
+ unless port.between?(1, 65535)
+ errors[:'mcp.port'] = "must be between 1 and 65535"
+ end
+ end
+
+ # Validate conditional requirements based on API type
+ if config[:msf_api][:type] == 'messagepack'
+ validate_messagepack_auth(config, errors)
+ elsif config[:msf_api][:type] == 'json-rpc'
+ validate_jsonrpc_auth(config, errors)
+ end
+
+ # Validate rate_limit section
+ if config.key?(:rate_limit)
+ if config[:rate_limit].is_a?(Hash)
+ validate_rate_limit(config, errors)
+ else
+ errors[:rate_limit] = "must be a configuration hash"
+ end
+ end
+
+ # Validate logging section
+ if config.key?(:logging)
+ if config[:logging].is_a?(Hash)
+ validate_logging(config, errors)
+ else
+ errors[:logging] = "must be a configuration hash"
+ end
+ end
+
+ # Raise error if any validation failed
+ unless errors.empty?
+ raise ValidationError.new(errors)
+ end
+
+ true
+ end
+
+ private
+
+ LOCALHOST_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
+
+ # Validate MessagePack authentication fields
+ #
+ # Credentials are optional when auto-start can generate random ones
+ # (auto_start_rpc enabled + localhost). If neither user nor password is
+ # provided under those conditions, validation passes and the RPC manager
+ # will generate random credentials at startup.
+ def validate_messagepack_auth(config, errors)
+ user_provided = config[:msf_api][:user] && !config[:msf_api][:user].to_s.strip.empty?
+ password_provided = config[:msf_api][:password] && !config[:msf_api][:password].to_s.strip.empty?
+
+ # Both provided — nothing to validate
+ return if user_provided && password_provided
+
+ # Neither provided and auto-start can generate them — OK
+ return if !user_provided && !password_provided && credentials_can_be_generated?(config)
+
+ # Otherwise, require both
+ unless user_provided
+ errors[:'msf_api.user'] = "is required for MessagePack authentication. Use --user option or MSF_API_USER environment variable"
+ end
+
+ unless password_provided
+ errors[:'msf_api.password'] = "is required for MessagePack authentication. Use --password option or MSF_API_PASSWORD environment variable"
+ end
+ end
+
+ # Whether the RPC manager can generate random credentials for this config.
+ #
+ # @param config [Hash] Configuration hash
+ # @return [Boolean]
+ def credentials_can_be_generated?(config)
+ config[:msf_api][:auto_start_rpc] != false &&
+ LOCALHOST_HOSTS.include?(config[:msf_api][:host].to_s.downcase)
+ end
+
+ # Validate JSON-RPC authentication fields
+ def validate_jsonrpc_auth(config, errors)
+ unless config[:msf_api][:token] && !config[:msf_api][:token].to_s.strip.empty?
+ errors[:'msf_api.token'] = "is required for JSON-RPC authentication"
+ end
+ end
+
+ # Validate rate_limit section fields
+ def validate_rate_limit(config, errors)
+ rate_limit = config[:rate_limit]
+
+ if rate_limit.key?(:enabled) && ![true, false].include?(rate_limit[:enabled])
+ errors[:'rate_limit.enabled'] = "must be boolean (true or false)"
+ end
+
+ if rate_limit.key?(:requests_per_minute)
+ unless rate_limit[:requests_per_minute].is_a?(Integer) && rate_limit[:requests_per_minute] >= 1
+ errors[:'rate_limit.requests_per_minute'] = "must be an integer >= 1"
+ end
+ end
+
+ if rate_limit.key?(:burst_size)
+ unless rate_limit[:burst_size].is_a?(Integer) && rate_limit[:burst_size] >= 1
+ errors[:'rate_limit.burst_size'] = "must be an integer >= 1"
+ end
+ end
+ end
+
+ VALID_LOG_LEVELS = %w[DEBUG INFO WARN ERROR].freeze
+
+ # Validate logging section fields
+ def validate_logging(config, errors)
+ logging = config[:logging]
+
+ if logging.key?(:enabled) && ![true, false].include?(logging[:enabled])
+ errors[:'logging.enabled'] = "must be boolean (true or false)"
+ end
+
+ if logging.key?(:level) && !VALID_LOG_LEVELS.include?(logging[:level].to_s.upcase)
+ errors[:'logging.level'] = "must be one of: #{VALID_LOG_LEVELS.join(', ')}"
+ end
+
+ if logging.key?(:log_file) && logging[:log_file].to_s.strip.empty?
+ errors[:'logging.log_file'] = "must be a non-empty string"
+ end
+
+ if logging.key?(:sanitize) && ![true, false].include?(logging[:sanitize])
+ errors[:'logging.sanitize'] = "must be boolean (true or false)"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/errors.rb b/lib/msf/core/mcp/errors.rb
new file mode 100644
index 0000000000000..0ba240c18a6d4
--- /dev/null
+++ b/lib/msf/core/mcp/errors.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ ##
+ # Base error class for all Msf::MCP errors
+ #
+ class Error < StandardError; end
+
+ ##
+ # Configuration Layer Errors
+ #
+ module Config
+
+ class ConfigurationError < Error; end
+
+ class ValidationError < Error
+ attr_reader :errors
+
+ def initialize(errors = {})
+ @errors = errors
+ super(build_message)
+ end
+
+ private
+
+ def build_message
+ return "Configuration validation failed" if @errors.empty?
+
+ messages = @errors.map { |field, error| "#{field} #{error}" }
+ "Configuration validation failed:\n - #{messages.join("\n - ")}"
+ end
+ end
+
+ end
+
+ ##
+ # Security Layer Errors
+ #
+ module Security
+
+ class ValidationError < Error; end
+
+ class RateLimitExceededError < Error
+ attr_reader :retry_after
+
+ def initialize(retry_after)
+ @retry_after = retry_after
+ super("Rate limit exceeded. Retry after #{retry_after} seconds.")
+ end
+ end
+
+ end
+
+ ##
+ # Metasploit Client Layer Errors
+ #
+ module Metasploit
+
+ class AuthenticationError < Error; end
+
+ class ConnectionError < Error; end
+
+ class APIError < Error; end
+
+ class RpcStartupError < Error; end
+
+ end
+
+end
diff --git a/lib/msf/core/mcp/logging/sinks/json_flatfile.rb b/lib/msf/core/mcp/logging/sinks/json_flatfile.rb
new file mode 100644
index 0000000000000..9581d6f69e43d
--- /dev/null
+++ b/lib/msf/core/mcp/logging/sinks/json_flatfile.rb
@@ -0,0 +1,24 @@
+# -*- coding: binary -*-
+module Msf::MCP
+ module Logging
+ module Sinks
+ ###
+ #
+ # This class implements the LogSink interface and backs it against a
+ # JSON file on disk.
+ #
+ ###
+ class JsonFlatfile < Msf::MCP::Logging::Sinks::JsonStream
+
+ #
+ # Creates a JSON flatfile log sink instance that will be configured to log to
+ # the supplied file path.
+ #
+ def initialize(file)
+ super(File.new(file, 'a'))
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/logging/sinks/json_stream.rb b/lib/msf/core/mcp/logging/sinks/json_stream.rb
new file mode 100644
index 0000000000000..b884cd287a549
--- /dev/null
+++ b/lib/msf/core/mcp/logging/sinks/json_stream.rb
@@ -0,0 +1,123 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Logging
+ module Sinks
+ # A Rex LogSink that formats log messages as JSON and writes them to
+ # an IO stream (e.g. $stdout, a File, a StringIO).
+ #
+ # @example Writing JSON logs to $stderr
+ # sink = Msf::MCP::Logging::Sinks::JsonStream.new($stderr)
+ # register_log_source('mcp', sink, Rex::Logging::LEV_0)
+ #
+ # @example Backed by a file via JsonFlatfile
+ # sink = Msf::MCP::Logging::Sinks::JsonFlatfile.new('msfmcp.log')
+ # register_log_source('mcp', sink, Rex::Logging::LEV_0)
+ class JsonStream
+ include Rex::Logging::LogSink
+
+ def initialize(stream)
+ @stream = stream
+ end
+
+ def log(sev, src, level, msg)
+ log_entry = {
+ timestamp: get_current_timestamp,
+ severity: sev.to_s.upcase,
+ level: level.to_s,
+ source: src.to_s,
+ message: msg.to_s
+ }
+
+ if msg.is_a?(Hash)
+ log_entry[:message] = msg[:message] if msg[:message] && !msg[:message].empty?
+ if msg[:context] && !msg[:context].empty?
+ log_entry[:context] = if debug_log_level?
+ msg[:context]
+ else
+ summarize_context(msg[:context])
+ end
+ end
+ if msg[:exception]
+ log_entry[:exception] = if msg[:exception].is_a?(Exception)
+ ex_msg = { class: msg[:exception].class.name, message: msg[:exception].message }
+ if get_log_level(LOG_SOURCE) >= BACKTRACE_LOG_LEVEL
+ ex_msg[:backtrace] = msg[:exception].backtrace&.first(5) || []
+ end
+ ex_msg
+ else
+ msg[:exception]
+ end
+ end
+ end
+
+ stream.write(log_entry.to_json + "\n")
+ stream.flush
+ end
+
+ def cleanup
+ stream.close
+ end
+
+ protected
+
+ attr_accessor :stream
+
+ private
+
+ # Keys whose values can be large (full API responses, tool results, etc.)
+ # and should be truncated at non-DEBUG log levels.
+ HEAVY_KEYS = %i[result body error].freeze
+
+ # Maximum character length for truncated values.
+ TRUNCATE_MAX_LENGTH = 1000
+
+ # Whether the current log level for the MCP source is at least DEBUG
+ # (LEV_3 / BACKTRACE_LOG_LEVEL), which enables full context output
+ # and exception backtraces.
+ #
+ # @return [Boolean]
+ def debug_log_level?
+ get_log_level(LOG_SOURCE) >= BACKTRACE_LOG_LEVEL
+ end
+
+ # Return a reduced copy of +ctx+ suitable for non-DEBUG log entries.
+ #
+ # Heavy keys (:result, :body, :error) are truncated. The :response sub-hash is also
+ # truncated. All other keys (scalars like :method, :elapsed_ms, :session_id) pass
+ # through unchanged.
+ #
+ # @param ctx [Hash] The original context hash
+ # @return [Hash] A summarized copy
+ def summarize_context(ctx)
+ return ctx unless ctx.is_a?(Hash)
+
+ ctx.each_with_object({}) do |(k, v), acc|
+ if HEAVY_KEYS.include?(k)
+ acc[k] = truncate_value(v)
+ elsif k == :response && v.is_a?(Hash)
+ acc[k] = v.each_with_object({}) do |(k_sub, v_sub), acc_sub|
+ acc_sub[k_sub] = HEAVY_KEYS.include?(k_sub) ? truncate_value(v_sub) : v_sub
+ end
+ else
+ acc[k] = v
+ end
+ end
+ end
+
+ # Truncate a value to a human-readable summary string.
+ #
+ # @param val [Object] The value to truncate
+ # @param max_length [Integer] Maximum character length before truncation
+ # @return [Object] The original value if short enough, otherwise a truncated string
+ def truncate_value(val, max_length: TRUNCATE_MAX_LENGTH)
+ str = val.is_a?(String) ? val : val.to_json
+ return val if str.length <= max_length
+
+ "#{str[0...max_length]}... (truncated, #{str.length} bytes)"
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/logging/sinks/sanitizing.rb b/lib/msf/core/mcp/logging/sinks/sanitizing.rb
new file mode 100644
index 0000000000000..666ce5f802104
--- /dev/null
+++ b/lib/msf/core/mcp/logging/sinks/sanitizing.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+require 'rex/logging/log_sink'
+
+module Msf::MCP
+ module Logging
+ module Sinks
+ # A Rex LogSink decorator that redacts sensitive information from log
+ # messages before delegating to a wrapped sink.
+ #
+ # @example Wrapping a JsonFlatfile sink
+ # inner = Msf::MCP::Logging::Sinks::JsonFlatfile.new('msfmcp.log')
+ # sink = Msf::MCP::Logging::Sinks::Sanitizing.new(inner)
+ # register_log_source('mcp', sink, Rex::Logging::LEV_0)
+ class Sanitizing
+ include Rex::Logging::LogSink
+
+ REDACTED = '[REDACTED]'
+
+ SENSITIVE_PATTERNS = {
+ password: /password[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i,
+ token_keyval: /token[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i,
+ token_header: /token\s+[a-zA-Z0-9_\-\.]+/i,
+ api_key: /api[_-]?key[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i,
+ secret: /secret[_-]?key[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i,
+ credential: /credential[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i,
+ auth: /auth[\"']?\s*[:=]\s*[\"']?[^\"',\s}]+/i,
+ bearer: /bearer\s+[a-zA-Z0-9_\-\.]+/i
+ }.freeze
+
+ SENSITIVE_KEYS = /\A(password|token|secret|api_key|api_secret|credential|auth_token|bearer|access_token|private_key)\z/i
+
+ # @param sink [Rex::Logging::LogSink] The underlying sink to write to
+ def initialize(sink)
+ @sink = sink
+ end
+
+ def log(sev, src, level, msg)
+ @sink.log(sev, src, level, sanitize(msg))
+ end
+
+ def cleanup
+ @sink.cleanup
+ end
+
+ private
+
+ # Sanitize data for logging by redacting sensitive information.
+ #
+ # @param data [Object] Data to sanitize (Hash, Array, String, or other)
+ # @return [Object] Sanitized copy of data
+ def sanitize(data)
+ case data
+ when Hash
+ data.each_with_object({}) do |(k, v), result|
+ result[k] = if k.to_s.match?(SENSITIVE_KEYS)
+ v.is_a?(Hash) || v.is_a?(Array) ? sanitize(v) : REDACTED
+ elsif k.to_sym == :exception && v.is_a?(Exception)
+ ex_msg = { class: v.class.name, message: sanitize(v.message) }
+ if get_log_level(LOG_SOURCE) >= BACKTRACE_LOG_LEVEL
+ bt = v.backtrace&.first(5) || []
+ bt = bt.map{|x| x.sub(/^.*lib\//, 'lib/') } # Dont expose the install path
+ ex_msg[:backtrace] = sanitize(bt)
+ end
+ ex_msg
+ else
+ sanitize(v)
+ end
+ end
+ when Array
+ data.map { |item| sanitize(item) }
+ when String
+ sanitize_string(data)
+ else
+ data
+ end
+ end
+
+ # Sanitize a string by redacting sensitive patterns
+ #
+ # @param str [String] String to sanitize
+ # @return [String] Sanitized string
+ def sanitize_string(str)
+ return str unless str.is_a?(String)
+
+ sanitized = str.dup
+
+ # Redact sensitive patterns - match entire pattern and replace value part
+ SENSITIVE_PATTERNS.each do |name, pattern|
+ sanitized = sanitized.gsub(pattern) do |match|
+ # For header-style tokens (token abc123, bearer abc123), replace the value
+ # # TODO: check this
+ if name == :token_header || name == :bearer
+ parts = match.split(/\s+/, 2)
+ "#{parts[0]} #{REDACTED}"
+ # For key-value style (token: abc123, password=abc123), replace after separator
+ elsif match =~ /(.*[:=])\s*[\"']?/
+ "#{Regexp.last_match[1]} #{REDACTED}"
+ else
+ REDACTED
+ end
+ end
+ end
+
+ sanitized
+ end
+
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/metasploit/client.rb b/lib/msf/core/mcp/metasploit/client.rb
new file mode 100644
index 0000000000000..2d7c91ca2e3c9
--- /dev/null
+++ b/lib/msf/core/mcp/metasploit/client.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require 'forwardable'
+
+module Msf::MCP
+ module Metasploit
+ # Client facade that routes to the appropriate protocol implementation
+ # Supports MessagePack RPC (Metasploit's native protocol) and JSON-RPC
+ class Client
+ extend Forwardable
+
+ def_delegators :@client, :authenticate, :search_modules, :module_info, :db_hosts, :db_services, :db_vulns, :db_notes, :db_creds, :db_loot, :shutdown
+
+ ##
+ # Initialize Metasploit client with explicit parameters
+ #
+ # @param api_type [String] API type: 'messagepack' or 'json-rpc'
+ # @param host [String] Metasploit host
+ # @param port [Integer] Metasploit port
+ # @param endpoint [String] API endpoint path
+ # @param token [String, nil] API token (for json-rpc)
+ # @param ssl [Boolean] Use SSL (default: true)
+ #
+ def initialize(api_type:, host:, port:, endpoint: nil, token: nil, ssl: true)
+ @client = create_client(api_type: api_type, host: host, port: port, endpoint: endpoint, token: token, ssl: ssl)
+ end
+
+ private
+
+ # Create the appropriate client based on API type
+ # @param api_type [String] API type: 'messagepack' or 'json-rpc'
+ # @param host [String] Metasploit host
+ # @param port [Integer] Metasploit port
+ # @param endpoint [String] API endpoint path
+ # @param token [String, nil] API token (for json-rpc)
+ # @param ssl [Boolean] Use SSL (default: true)
+ # @return [MessagePackClient, JsonRpcClient] Client instance
+ # @raise [Error] If invalid API type specified
+ def create_client(api_type:, host:, port:, endpoint: nil, token: nil, ssl: true)
+ case api_type
+ when 'messagepack'
+ require_relative 'messagepack_client'
+ MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint || MessagePackClient::DEFAULT_ENDPOINT,
+ ssl: ssl
+ )
+ when 'json-rpc'
+ require_relative 'jsonrpc_client'
+ JsonRpcClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint || JsonRpcClient::DEFAULT_ENDPOINT,
+ ssl: ssl,
+ token: token
+ )
+ else
+ raise Error, "Invalid API type: #{api_type}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/metasploit/jsonrpc_client.rb b/lib/msf/core/mcp/metasploit/jsonrpc_client.rb
new file mode 100644
index 0000000000000..ac626859d9224
--- /dev/null
+++ b/lib/msf/core/mcp/metasploit/jsonrpc_client.rb
@@ -0,0 +1,199 @@
+# frozen_string_literal: true
+
+require 'net/http'
+require 'json'
+
+module Msf::MCP
+ module Metasploit
+ # JSON-RPC 2.0 client for Metasploit Framework
+ # Implements bearer token authentication for the Metasploit JSON-RPC API
+ # Endpoint: /api/v1/json-rpc (default port 8081)
+ # See: lib/msf/core/rpc/json/ in Metasploit Framework repository
+ class JsonRpcClient
+ DEFAULT_ENDPOINT = '/api/v1/json-rpc'
+
+ # Initialize JSON-RPC client
+ # @param host [String] Metasploit RPC host
+ # @param port [Integer] Metasploit RPC port
+ # @param endpoint [String] API endpoint path (default: DEFAULT_ENDPOINT)
+ # @param token [String] Bearer authentication token
+ # @param ssl [Boolean] Use SSL (default: true)
+ def initialize(host:, port:, endpoint: DEFAULT_ENDPOINT, token:, ssl: true)
+ @host = host
+ @port = port
+ @endpoint = endpoint
+ @token = token
+ @request_id = 0
+ @http = nil
+ @ssl = ssl
+ end
+
+ # No-op for JSON-RPC: authentication uses a pre-configured bearer token.
+ # This method exists so that JsonRpcClient satisfies the same interface as
+ # MessagePackClient, allowing the Client facade to delegate uniformly.
+ #
+ # @param _user [String] Ignored
+ # @param _password [String] Ignored
+ # @return [String] The existing token
+ def authenticate(_user, _password)
+ @token
+ end
+
+ # Call Metasploit API method using JSON-RPC 2.0 format
+ # @param method [String] API method name
+ # @param args [Array] Arguments to pass to the method (must be an array)
+ # @return [Hash] API response
+ # @raise [AuthenticationError] If token is invalid
+ # @raise [APIError] If API returns error
+ # @raise [ConnectionError] If connection fails
+ # @raise [ArgumentError] If args is not an array
+ def call_api(method, args = [])
+ raise ArgumentError, "args must be an Array, got #{args.class}" unless args.is_a?(Array)
+
+ @request_id += 1
+
+ # Build JSON-RPC 2.0 request as a hash
+ request_body = {
+ jsonrpc: '2.0',
+ method: method,
+ params: args,
+ id: @request_id
+ }
+
+ # Send HTTP request
+ response = send_request(request_body)
+
+ # Check for JSON-RPC error
+ if response['error']
+ error_msg = response['error']['message'] || 'Unknown error'
+ raise APIError, error_msg
+ end
+
+ response['result']
+ end
+
+ # Search for Metasploit modules
+ # @param query [String] Search query
+ # @return [Array] Module metadata
+ def search_modules(query)
+ call_api('module.search', [query])
+ end
+
+ # Get module information
+ # @param type [String] Module type ('exploit', 'auxiliary', 'post', etc.)
+ # @param name [String] Module name
+ # @return [Hash] Module information
+ def module_info(type, name)
+ call_api('module.info', [type, name])
+ end
+
+ # Get hosts from database
+ # @param options [Hash] Query options (workspace, limit, offset, etc.)
+ # @return [Hash] Response with 'hosts' array
+ def db_hosts(options = {})
+ call_api('db.hosts', [options])
+ end
+
+ # Get services from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'services' array
+ def db_services(options = {})
+ call_api('db.services', [options])
+ end
+
+ # Get vulnerabilities from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'vulns' array
+ def db_vulns(options = {})
+ call_api('db.vulns', [options])
+ end
+
+ # Get notes from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'notes' array
+ def db_notes(options = {})
+ call_api('db.notes', [options])
+ end
+
+ # Get credentials from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'creds' array
+ def db_creds(options = {})
+ call_api('db.creds', [options])
+ end
+
+ # Get loot from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'loots' array
+ def db_loot(options = {})
+ call_api('db.loots', [options])
+ end
+
+ # Shutdown client
+ def shutdown
+ @http&.finish if @http&.started?
+ @http = nil
+ end
+
+ private
+
+ # Send HTTP POST request with JSON-RPC payload
+ # @param request_body [Hash] JSON-RPC request body as a hash
+ # @return [Hash] Parsed response
+ # @raise [ConnectionError] If connection fails
+ # @raise [AuthenticationError] If token is invalid
+ def send_request(request_body)
+ # Create HTTP client if needed
+ unless @http
+ @http = Net::HTTP.new(@host, @port)
+ @http.use_ssl = @ssl
+ @http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @ssl
+ end
+
+ # Create POST request
+ request = Net::HTTP::Post.new(@endpoint)
+ request['Content-Type'] = 'application/json'
+ request['Authorization'] = "Bearer #{@token}"
+ request.body = request_body.to_json
+
+ dlog({
+ message: 'JSON-RPC request',
+ context: { method: request.method, endpoint: @endpoint, body: request_body }
+ }, LOG_SOURCE, LOG_DEBUG)
+
+ # Send request and parse response
+ begin
+ response = @http.request(request)
+
+ parsed = case response.code.to_i
+ when 200
+ JSON.parse(response.body)
+ when 401
+ raise AuthenticationError, 'Invalid authentication token'
+ when 500
+ error_data = JSON.parse(response.body) rescue { 'error' => { 'message' => 'Internal server error' } }
+ error_msg = error_data.dig('error', 'message') || 'Internal server error'
+ raise APIError, error_msg
+ else
+ raise ConnectionError, "HTTP #{response.code}: #{response.message}"
+ end
+
+ dlog({
+ message: 'JSON-RPC response',
+ context: { status: response.code, body: parsed }
+ }, LOG_SOURCE, LOG_DEBUG)
+
+ parsed
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
+ raise ConnectionError, "Cannot connect to Metasploit RPC: #{e.message}"
+ rescue SocketError => e
+ raise ConnectionError, "Network error: #{e.message}"
+ rescue Timeout::Error => e
+ raise ConnectionError, "Request timeout: #{e.message}"
+ rescue EOFError => e
+ raise ConnectionError, "Empty response from Metasploit RPC: #{e.message}"
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/metasploit/messagepack_client.rb b/lib/msf/core/mcp/metasploit/messagepack_client.rb
new file mode 100644
index 0000000000000..ef96c99b1fe49
--- /dev/null
+++ b/lib/msf/core/mcp/metasploit/messagepack_client.rb
@@ -0,0 +1,262 @@
+# frozen_string_literal: true
+
+require 'net/http'
+require 'msgpack'
+
+module Msf::MCP
+ module Metasploit
+ # MessagePack RPC client for Metasploit Framework
+ # Implements authentication and API calls using MessagePack serialization
+ class MessagePackClient
+ DEFAULT_ENDPOINT = '/api/'
+
+ # Initialize MessagePack client
+ # @param host [String] Metasploit RPC host
+ # @param port [Integer] Metasploit RPC port
+ # @param endpoint [String] API endpoint path (default: DEFAULT_ENDPOINT)
+ # @param ssl [Boolean] Use SSL (default: true)
+ def initialize(host:, port:, endpoint: DEFAULT_ENDPOINT, ssl: true)
+ @host = host
+ @port = port
+ @endpoint = endpoint
+ @token = nil
+ @http = nil
+ @user = nil
+ @password = nil
+ @retry_count = 0
+ @max_retries = 2
+ @ssl = ssl
+ end
+
+ # Authenticate with Metasploit RPC
+ # @param user [String] Username
+ # @param password [String] Password
+ # @return [String] The resulting token if authentication successful
+ # @raise [AuthenticationError] If authentication fails
+ def authenticate(user, password)
+ # Store credentials for automatic re-authentication
+ @user = user
+ @password = password
+
+ # Send authentication request directly (bypass retry logic)
+ request_array = ['auth.login', user, password]
+ response = send_request(request_array)
+
+ # Real Metasploit API returns string keys
+ if response['result'] == 'success' && response['token']
+ @token = response['token']
+ elsif response['error']
+ raise AuthenticationError, response['error']
+ else
+ raise AuthenticationError, 'Authentication failed'
+ end
+ end
+
+ # Call Metasploit RPC API method
+ # @param method [String] API method name (e.g., 'module.search')
+ # @param args [Array] Arguments to pass to the method (must be an array)
+ # @return [Hash, Array] API response
+ # @raise [AuthenticationError] If authentication fails
+ # @raise [APIError] If API returns an error
+ # @raise [ConnectionError] If connection fails
+ # @raise [ArgumentError] If args is not an array
+ def call_api(method, args = [])
+ raise ArgumentError, "args must be an Array, got #{args.class}" unless args.is_a?(Array)
+
+ begin
+ raise AuthenticationError, 'Not authenticated' unless @token
+
+ # Build request array: [method, token, *args]
+ request_array = [method, @token, *args]
+
+ # Send HTTP request
+ send_request(request_array)
+
+ rescue AuthenticationError => e
+ # It is not possible to reauthenticate if we don't have credentials stored
+ raise unless @user && @password
+ # If reauthentication succeeded but the token is still invalid, we should not retry indefinitely
+ raise unless @retry_count < @max_retries
+
+ @retry_count += 1
+ @token = nil
+
+ begin
+ wlog({ message: "#{method}': #{e.message}. Attempting to re-authenticate (#{@retry_count}/#{@max_retries})" },
+ LOG_SOURCE, LOG_WARN)
+ authenticate(@user, @password)
+ rescue AuthenticationError => auth_e
+ wlog({ message: "Re-authentication failed: #{auth_e.message}" },
+ LOG_SOURCE, LOG_WARN)
+ if @retry_count < @max_retries
+ @retry_count += 1
+ @token = nil
+ retry
+ end
+ raise AuthenticationError, "Unable to authenticate after #{@retry_count} attempts: #{auth_e.message}"
+ end
+
+ # Retry the original request with new token
+ retry
+ end
+
+ rescue Msf::MCP::Error => e
+ elog({ message: 'MessagePack API call error', context: { error: e.message } },
+ LOG_SOURCE, LOG_ERROR)
+ raise
+ ensure
+ @retry_count = 0
+ end
+
+ # Search for Metasploit modules
+ # @param query [String] Search query
+ # @return [Array] Module metadata
+ def search_modules(query)
+ call_api('module.search', [query])
+ end
+
+ # Get module information
+ # @param type [String] Module type ('exploit', 'auxiliary', 'post', etc.)
+ # @param name [String] Module name
+ # @return [Hash] Module information
+ def module_info(type, name)
+ call_api('module.info', [type, name])
+ end
+
+ # Get hosts from database
+ # @param options [Hash] Query options (workspace, limit, offset, etc.)
+ # @return [Hash] Response with 'hosts' array
+ def db_hosts(options = {})
+ call_api('db.hosts', [options])
+ end
+
+ # Get services from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'services' array
+ def db_services(options = {})
+ call_api('db.services', [options])
+ end
+
+ # Get vulnerabilities from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'vulns' array
+ def db_vulns(options = {})
+ call_api('db.vulns', [options])
+ end
+
+ # Get notes from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'notes' array
+ def db_notes(options = {})
+ call_api('db.notes', [options])
+ end
+
+ # Get credentials from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'creds' array
+ def db_creds(options = {})
+ call_api('db.creds', [options])
+ end
+
+ # Get loot from database
+ # @param options [Hash] Query options
+ # @return [Hash] Response with 'loots' array
+ def db_loot(options = {})
+ call_api('db.loots', [options])
+ end
+
+ # Shutdown client and cleanup
+ def shutdown
+ @token = nil
+ @user = nil
+ @password = nil
+ @http&.finish if @http&.started?
+ @http = nil
+ end
+
+ private
+
+ # Send HTTP POST request with MessagePack payload
+ # @param request_array [Array] Request data
+ # @return [Hash, Array] Parsed response
+ # @raise [AuthenticationError] If the token is not valid
+ # @raise [APIError] If the Metasploit API returns an error
+ # @raise [ConnectionError] If connection fails
+ def send_request(request_array)
+ # Create HTTP client if needed
+ unless @http
+ @http = Net::HTTP.new(@host, @port)
+ @http.use_ssl = @ssl
+ @http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @ssl
+ end
+
+ # Encode request with MessagePack
+ request_body = request_array.to_msgpack
+
+ # Create POST request
+ request = Net::HTTP::Post.new(@endpoint)
+ request['Content-Type'] = 'binary/message-pack'
+ request.body = request_body
+
+ dlog({
+ message: 'MessagePack request',
+ context: { method: request.method, endpoint: @endpoint, body: sanitize_request_array(request_array) }
+ }, LOG_SOURCE, LOG_DEBUG)
+
+ # Send request and parse response
+ begin
+ response = @http.request(request)
+
+ parsed = case response.code.to_i
+ when 200
+ MessagePack.unpack(response.body)
+ when 401
+ error_data = MessagePack.unpack(response.body) rescue { 'error_message' => 'Authentication error' }
+ error_msg = error_data['error_message'] || error_data['error_string'] || 'Authentication error'
+ raise AuthenticationError, error_msg
+ when 500
+ error_data = MessagePack.unpack(response.body) rescue { 'error_message' => 'Internal server error' }
+ error_msg = error_data['error_message'] || error_data['error_string'] || 'Internal server error'
+ raise APIError, error_msg
+ else
+ raise ConnectionError, "HTTP #{response.code}: #{response.message}"
+ end
+
+ dlog({
+ message: 'MessagePack response',
+ context: { status: response.code, body: parsed }
+ }, LOG_SOURCE, LOG_DEBUG)
+
+ parsed
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
+ raise ConnectionError, "Cannot connect to Metasploit RPC: #{e.message}"
+ rescue SocketError => e
+ raise ConnectionError, "Network error: #{e.message}"
+ rescue Timeout::Error => e
+ raise ConnectionError, "Request timeout: #{e.message}"
+ rescue EOFError => e
+ raise ConnectionError, "Empty response from Metasploit RPC: #{e.message}"
+ end
+ end
+
+ REDACTED = '[REDACTED]'
+
+ # Sanitize request array for logging by redacting sensitive positional values
+ #
+ # For auth.login requests: redacts the password (last element)
+ # For API calls: redacts the token (second element)
+ #
+ # @param request_array [Array] Raw request array
+ # @return [Array] Sanitized copy with sensitive values redacted
+ def sanitize_request_array(request_array)
+ sanitized = request_array.dup
+ if sanitized[0] == 'auth.login'
+ sanitized[-1] = REDACTED
+ elsif sanitized.length > 1
+ sanitized[1] = REDACTED
+ end
+ sanitized
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/metasploit/response_transformer.rb b/lib/msf/core/mcp/metasploit/response_transformer.rb
new file mode 100644
index 0000000000000..4a6999aadbc14
--- /dev/null
+++ b/lib/msf/core/mcp/metasploit/response_transformer.rb
@@ -0,0 +1,216 @@
+# frozen_string_literal: true
+
+require 'time'
+
+module Msf::MCP
+ module Metasploit
+ # Transforms Metasploit RPC responses into MCP-compatible format
+ # Adds metadata, converts field names, and formats timestamps
+ class ResponseTransformer
+ # Transform module search results
+ # @param modules [Array] Raw module data from Metasploit
+ # @return [Array] Transformed modules with MCP metadata
+ def self.transform_modules(modules)
+ return [] unless modules.is_a?(Array)
+
+ modules.map do |mod|
+ {
+ name: mod['name'] || mod['fullname'],
+ type: mod['type'],
+ fullname: mod['fullname'],
+ rank: mod['rank'],
+ disclosure_date: mod['disclosuredate']
+ }.compact
+ end
+ end
+
+ # Transform module info response
+ # @param info [Hash] Raw module info from Metasploit
+ # @return [Hash] Transformed info with MCP metadata
+ def self.transform_module_info(info)
+ return {} unless info.is_a?(Hash)
+
+ {
+ type: info['type'],
+ name: info['name'],
+ fullname: info['fullname'],
+ rank: info['rank'],
+ disclosure_date: info['disclosuredate'],
+ description: info['description'],
+ license: info['license'],
+ filepath: info['filepath']&.sub(/^.*modules\//, 'modules/'), # Dont expose the install path
+ architectures: info['arch'],
+ platforms: info['platform'],
+ authors: info['authors'],
+ privileged: info['privileged'],
+ has_check_method: info['check'],
+ # TODO: write transformer for default_options
+ default_options: info['default_options'],
+ references: transform_references(info['references']),
+ targets: info['targets'],
+ default_target: info['default_target'],
+ stance: info['stance'],
+ actions: info['actions'],
+ default_action: info['default_action'],
+ # TODO: write transformer for options
+ options: info['options']
+ }.compact
+ end
+
+ # Transform hosts response
+ # @param response [Hash] Raw response with 'hosts' array
+ # @return [Array] Transformed hosts with MCP metadata
+ def self.transform_hosts(response)
+ return [] unless response.is_a?(Hash) && response['hosts'].is_a?(Array)
+
+ response['hosts'].map do |host|
+ {
+ created_at: format_timestamp(host['created_at']),
+ address: host['address'],
+ mac_address: host['mac'],
+ hostname: host['name'],
+ state: host['state'],
+ os_name: host['os_name'],
+ os_flavor: host['os_flavor'],
+ os_service_pack: host['os_sp'],
+ os_language: host['os_lang'],
+ updated_at: format_timestamp(host['updated_at']),
+ purpose: host['purpose'],
+ info: host['info']
+ }.compact
+ end
+ end
+
+ # Transform services response
+ # @param response [Hash] Raw response with 'services' array
+ # @return [Array] Transformed services
+ def self.transform_services(response)
+ return [] unless response.is_a?(Hash) && response['services'].is_a?(Array)
+
+ response['services'].map do |service|
+ {
+ host_address: service['host'],
+ created_at: format_timestamp(service['created_at']),
+ updated_at: format_timestamp(service['updated_at']),
+ port: service['port'],
+ protocol: service['proto'],
+ state: service['state'],
+ name: service['name'],
+ info: service['info'],
+ }.compact
+ end
+ end
+
+ # Transform vulnerabilities response
+ # @param response [Hash] Raw response with 'vulns' array
+ # @return [Array] Transformed vulnerabilities
+ def self.transform_vulns(response)
+ return [] unless response.is_a?(Hash) && response['vulns'].is_a?(Array)
+
+ response['vulns'].map do |vuln|
+ {
+ host: vuln['host'],
+ port: vuln['port'],
+ protocol: vuln['proto'],
+ name: vuln['name'],
+ references: parse_refs(vuln['refs']),
+ created_at: format_timestamp(vuln['time'])
+ }.compact
+ end
+ end
+
+ # Transform notes response
+ # @param response [Hash] Raw response with 'notes' array
+ # @return [Array] Transformed notes
+ def self.transform_notes(response)
+ return [] unless response.is_a?(Hash) && response['notes'].is_a?(Array)
+
+ response['notes'].map do |note|
+ {
+ host: note['host'],
+ service_name_or_port: note['service'],
+ note_type: note['type'] || note['ntype'],
+ data: note['data'],
+ created_at: format_timestamp(note['time'])
+ }.compact
+ end
+ end
+
+ # Transform credentials response
+ # @param response [Hash] Raw response with 'creds' array
+ # @return [Array] Transformed credentials
+ def self.transform_creds(response)
+ return [] unless response.is_a?(Hash) && response['creds'].is_a?(Array)
+
+ response['creds'].map do |cred|
+ {
+ host: cred['host'],
+ port: cred['port'],
+ protocol: cred['proto'],
+ service_name: cred['sname'],
+ user: cred['user'],
+ secret: cred['pass'],
+ type: cred['type'],
+ updated_at: format_timestamp(cred['updated_at'])
+ }.compact
+ end
+ end
+
+ # Transform loot response
+ # @param response [Hash] Raw response with 'loots' array
+ # @return [Array] Transformed loot
+ def self.transform_loot(response)
+ return [] unless response.is_a?(Hash) && response['loots'].is_a?(Array)
+
+ response['loots'].map do |loot|
+ {
+ host: loot['host'],
+ service_name_or_port: loot['service'],
+ loot_type: loot['ltype'],
+ content_type: loot['ctype'],
+ name: loot['name'],
+ info: loot['info'],
+ data: loot['data'],
+ created_at: format_timestamp(loot['created_at']),
+ updated_at: format_timestamp(loot['updated_at'])
+ }.compact
+ end
+ end
+
+ private
+
+ # Convert Unix epoch timestamp to ISO 8601 format
+ # @param timestamp [Integer, nil] Unix timestamp
+ # @return [String, nil] ISO 8601 formatted string
+ def self.format_timestamp(timestamp)
+ return nil if timestamp.nil? || timestamp.to_i.zero?
+ Time.at(timestamp.to_i).utc.iso8601
+ end
+
+ # Transform references array
+ # @param refs [Array, nil] References from Metasploit
+ # @return [Array, nil] Transformed references
+ def self.transform_references(refs)
+ return nil unless refs.is_a?(Array)
+
+ refs.map do |ref|
+ if ref.is_a?(Array) && ref.length == 2
+ { type: ref[0], value: ref[1] }
+ else
+ ref
+ end
+ end
+ end
+
+ # Parse comma-separated reference string
+ # Note there can have some issues if the ref values themselves contain commas,
+ # but it is the way the MSF RPC API returns them.
+ # @param refs [String, nil] Comma-separated refs
+ # @return [Array, nil] Array of references
+ def self.parse_refs(refs)
+ return nil if refs.nil? || refs.empty?
+ refs.to_s.split(',').map(&:strip).reject(&:empty?)
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/middleware/request_logger.rb b/lib/msf/core/mcp/middleware/request_logger.rb
new file mode 100644
index 0000000000000..d2154d3bcb2bb
--- /dev/null
+++ b/lib/msf/core/mcp/middleware/request_logger.rb
@@ -0,0 +1,187 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Middleware
+ ##
+ # Rack middleware that logs MCP HTTP request/response details via Rex logging.
+ #
+ # Focuses on the HTTP transport layer: request method, status code, session ID,
+ # content type, and round-trip timing. For POST requests it also extracts
+ # JSON-RPC fields (method, id, params) and response result/error to provide
+ # DEBUG-level visibility into the exchange.
+ #
+ # MCP-level business details (tool names, tool durations, and structured
+ # results) are handled by the SDK's +around_request+ callback configured
+ # in Server, avoiding duplication.
+ #
+ # @example Usage in a Rack::Builder
+ # Rack::Builder.new do
+ # use Msf::MCP::Middleware::RequestLogger
+ # run transport
+ # end
+ #
+ class RequestLogger
+
+ ##
+ # @param app [#call] The next Rack application in the middleware stack
+ #
+ def initialize(app)
+ @app = app
+ end
+
+ ##
+ # Process the request, delegating to the next Rack app and logging
+ # transport-level details after the response is produced.
+ #
+ # @param env [Hash] The Rack environment
+ # @return [Array] The Rack response triplet [status, headers, body]
+ #
+ def call(env)
+ request = Rack::Request.new(env)
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
+
+ response = @app.call(env)
+
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
+ log_exchange(request, response, elapsed)
+
+ response
+ end
+
+ private
+
+ ##
+ # Log a single request/response entry at the HTTP transport level.
+ #
+ # Dispatches to {#log_post_exchange} for POST requests (which extracts
+ # JSON-RPC fields). GET, DELETE, and other methods are logged directly
+ # with status and timing information.
+ #
+ # @param request [Rack::Request] The incoming HTTP request
+ # @param response [Array] The Rack response [status, headers, body]
+ # @param elapsed [Float] Wall-clock seconds for the round-trip
+ #
+ def log_exchange(request, response, elapsed)
+ status, headers, _body = response
+ session_id = request.env['HTTP_MCP_SESSION_ID'] || headers&.fetch('Mcp-Session-Id', nil)
+ elapsed_ms = (elapsed * 1000).round(2)
+
+ context = { elapsed_ms: elapsed_ms }
+ context[:session_id] = session_id if session_id
+
+ case request.request_method
+ when 'POST'
+ log_post_exchange(request, response, context)
+ when 'GET'
+ context[:response] = build_response_context(response)
+ ilog({ message: "SSE stream opened (#{elapsed_ms}ms)", context: context }, LOG_SOURCE, LOG_INFO)
+ when 'DELETE'
+ context[:response] = build_response_context(response)
+ ilog({ message: "Session deleted (#{elapsed_ms}ms)", context: context }, LOG_SOURCE, LOG_INFO)
+ else
+ context[:response] = build_response_context(response)
+ dlog({ message: "HTTP #{request.request_method} #{status} (#{elapsed_ms}ms)", context: context }, LOG_SOURCE, LOG_DEBUG)
+ end
+ end
+
+ ##
+ # Log a POST exchange with JSON-RPC params and response result/error
+ # nested under :request and :response keys.
+ #
+ # For streaming responses (Proc body), the result is not available here —
+ # it is logged by the +around_request+ callback in Server instead.
+ #
+ # Distinguishes between:
+ # - Notifications (no id): logged at DEBUG since the SDK instrumentation
+ # does not fire for these
+ # - Requests with HTTP errors: logged at ERROR with the error details
+ # - Normal requests: logged at DEBUG with params and result
+ # (the +around_request+ callback provides the INFO-level business log)
+ #
+ # @param request [Rack::Request] The incoming HTTP request
+ # @param response [Array] The Rack response [status, headers, body]
+ # @param context [Hash] Pre-built context hash with session_id and elapsed_ms
+ #
+ def log_post_exchange(request, response, context)
+ context[:request] = {}
+ jsonrpc = extract_jsonrpc_fields(request)
+ if jsonrpc
+ context[:request][:method] = jsonrpc[:method] if jsonrpc[:method]
+ context[:request][:id] = jsonrpc[:id] if jsonrpc[:id]
+ context[:request][:params] = jsonrpc[:params] if jsonrpc[:params]
+ end
+
+ context[:response] = build_response_context(response)
+ response_body = extract_response_body(response)
+ if response_body
+ context[:response][:result] = response_body[:result] if response_body[:result]
+ context[:response][:error] = response_body[:error] if response_body[:error]
+ end
+
+ method_name = context[:request][:method] || 'unknown'
+ if context[:request][:id].nil? && context[:request][:method]
+ # Notification — no instrumentation fires for these
+ dlog({ message: "Notification: #{method_name} #{context[:response][:status]} (#{context[:elapsed_ms]}ms)", context: context }, LOG_SOURCE, LOG_DEBUG)
+ elsif context[:response][:status] >= 400
+ elog({ message: "HTTP #{context[:response][:status]}: #{method_name} (#{context[:elapsed_ms]}ms)", context: context }, LOG_SOURCE, LOG_ERROR)
+ else
+ dlog({ message: "HTTP #{context[:response][:status]}: #{method_name} id=#{context[:request][:id]} (#{context[:elapsed_ms]}ms)", context: context }, LOG_SOURCE, LOG_DEBUG)
+ end
+ end
+
+ ##
+ # Build the response portion of the log context from the Rack response.
+ #
+ # @param response [Array] The Rack response [status, headers, body]
+ # @return [Hash] Response context with :status and :content_type
+ #
+ def build_response_context(response)
+ status, headers, _body = response
+ res = { status: status }
+ res[:content_type] = headers['Content-Type'] if headers&.key?('Content-Type')
+ res
+ end
+
+ ##
+ # Extract JSON-RPC method, id, and params from the request body.
+ #
+ # Rewinds before and after reading so downstream handlers can still
+ # consume the body.
+ #
+ # @param request [Rack::Request] The incoming HTTP request
+ # @return [Hash, nil] Parsed fields (:method, :id, :params), or nil on
+ # parse failure
+ #
+ def extract_jsonrpc_fields(request)
+ request.body.rewind
+ body = request.body.read
+ request.body.rewind
+ parsed = JSON.parse(body)
+ { method: parsed['method'], id: parsed['id'], params: parsed['params'] }
+ rescue JSON::ParserError
+ nil
+ end
+
+ ##
+ # Extract result or error from the response body.
+ #
+ # Only parses Array bodies (direct JSON responses). SSE stream responses
+ # (Proc bodies) are not parseable here — their results are logged by the
+ # +around_request+ callback in Server.
+ #
+ # @param response [Array] The Rack response [status, headers, body]
+ # @return [Hash, nil] Parsed fields (:result, :error), or nil if the body
+ # is empty, non-Array, or unparseable
+ #
+ def extract_response_body(response)
+ _status, _headers, body = response
+ return nil unless body.is_a?(Array) && !body.empty?
+
+ parsed = JSON.parse(body.first)
+ { result: parsed['result'], error: parsed['error'] }
+ rescue JSON::ParserError
+ nil
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/rpc_manager.rb b/lib/msf/core/mcp/rpc_manager.rb
new file mode 100644
index 0000000000000..ee37460412f50
--- /dev/null
+++ b/lib/msf/core/mcp/rpc_manager.rb
@@ -0,0 +1,302 @@
+# frozen_string_literal: true
+
+require 'securerandom'
+require 'socket'
+
+module Msf::MCP
+ # Manages the lifecycle of a Metasploit RPC server process.
+ #
+ # Probes the configured RPC port, auto-starts the server via Process.spawn
+ # of msfrpcd, and cleans up the child process on shutdown.
+ class RpcManager
+ LOCALHOST_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
+ DEFAULT_WAIT_TIMEOUT = 30
+ DEFAULT_WAIT_INTERVAL = 1
+ STOP_GRACE_PERIOD = 5
+
+ attr_reader :rpc_pid
+
+ # @param config [Hash] Application configuration hash
+ # @param output [IO] Output stream for status messages
+ def initialize(config:, output:)
+ @config = config
+ @output = output
+ @rpc_pid = nil
+ @rpc_managed = false
+ end
+
+ # Whether this manager started and is managing an RPC server process.
+ #
+ # @return [Boolean]
+ def rpc_managed?
+ @rpc_managed
+ end
+
+ # Probe the configured RPC port to check if a server is listening.
+ #
+ # @return [Boolean]
+ def rpc_available?
+ host = @config[:msf_api][:host]
+ port = @config[:msf_api][:port]
+
+ socket = Rex::Socket::Tcp.create(
+ 'PeerHost' => host,
+ 'PeerPort' => port
+ )
+ socket.close
+ dlog({ message: "RPC server is available at #{Rex::Socket.to_authority(host, port)}" },
+ LOG_SOURCE, LOG_DEBUG)
+ true
+ rescue Rex::ConnectionError
+ false
+ end
+
+ # Whether auto-start is enabled based on config, API type, and host.
+ #
+ # Auto-start is only supported for:
+ # - MessagePack API type (not JSON-RPC)
+ # - Localhost connections (cannot start a remote RPC server)
+ # - When auto_start_rpc config is not explicitly false
+ #
+ # @return [Boolean]
+ def auto_start_enabled?
+ return false if @config[:msf_api][:type] != 'messagepack'
+ return false unless localhost?
+ return false if @config[:msf_api][:auto_start_rpc] == false
+
+ true
+ end
+
+ # Start the Metasploit RPC server by spawning msfrpcd.
+ #
+ # Credentials are passed via environment variables to avoid exposing
+ # them on the command line.
+ #
+ # @return [void]
+ # @raise [Msf::MCP::Metasploit::RpcStartupError] If the server cannot be started
+ def start_rpc_server
+ if @rpc_managed
+ @output.puts 'RPC server is already managed by this process'
+ return
+ end
+
+ @output.puts 'Starting Metasploit RPC server...'
+ ilog({ message: 'Starting Metasploit RPC server' },
+ LOG_SOURCE, LOG_INFO)
+
+ unless File.executable?(MSFRPCD_PATH)
+ raise Msf::MCP::Metasploit::RpcStartupError,
+ 'msfrpcd executable not found. Cannot auto-start RPC server.'
+ end
+
+ args = build_msfrpcd_args
+ env = {
+ 'MSF_RPC_USER' => @config[:msf_api][:user].to_s,
+ 'MSF_RPC_PASS' => @config[:msf_api][:password].to_s
+ }
+
+ pid = Process.spawn(env, MSFRPCD_PATH, *args, %i[out err] => File::NULL)
+
+ @rpc_pid = pid
+ @rpc_managed = true
+ @output.puts "RPC server started via msfrpcd (PID: #{pid})"
+ end
+
+ # Wait for the RPC server to become available.
+ #
+ # @param timeout [Integer] Maximum seconds to wait (default: 30)
+ # @param interval [Integer] Seconds between probes (default: 1)
+ # @return [true] When the server becomes available
+ # @raise [Msf::MCP::Metasploit::ConnectionError] If timeout is reached
+ # @raise [Msf::MCP::Metasploit::RpcStartupError] If the managed process exits
+ def wait_for_rpc(timeout: DEFAULT_WAIT_TIMEOUT, interval: DEFAULT_WAIT_INTERVAL)
+ deadline = Time.now + timeout
+
+ loop do
+ if rpc_available?
+ @output.puts 'RPC server is ready'
+ return true
+ end
+
+ check_managed_process_alive! if @rpc_managed
+
+ if Time.now >= deadline
+ raise Msf::MCP::Metasploit::ConnectionError,
+ "Timed out waiting for RPC server after #{timeout} seconds"
+ end
+
+ @output.puts 'Waiting for RPC server to become available...'
+ sleep(interval)
+ end
+ end
+
+ # Stop the managed RPC server process.
+ #
+ # @return [void]
+ def stop_rpc_server
+ return unless @rpc_managed
+
+ @output.puts 'Stopping managed RPC server...'
+ ilog({ message: "Stopping managed RPC server (PID: #{@rpc_pid})" },
+ LOG_SOURCE, LOG_INFO)
+
+ begin
+ Process.kill('TERM', @rpc_pid)
+ graceful_wait
+ rescue Errno::ESRCH
+ # Process already dead — that's fine
+ rescue Errno::EPERM
+ @output.puts "Warning: no permission to stop RPC process #{@rpc_pid}"
+ end
+
+ @rpc_pid = nil
+ @rpc_managed = false
+ end
+
+ # Ensure an RPC server is available, auto-starting if needed.
+ #
+ # When the RPC server is already listening, verifies that credentials
+ # (or a token for JSON-RPC) are available for the caller to authenticate.
+ #
+ # When the server is not available, auto-start is attempted only for
+ # MessagePack on localhost with auto_start_rpc enabled. Random
+ # credentials are generated when none are provided.
+ #
+ # @return [void]
+ # @raise [Msf::MCP::Metasploit::RpcStartupError] If the server cannot be
+ # reached and auto-start is not possible, or if the server is running
+ # but no credentials/token were provided
+ def ensure_rpc_available
+ if rpc_available?
+ @output.puts 'Metasploit RPC server is already running'
+ validate_credentials_for_existing_server!
+ return
+ end
+
+ if @config[:msf_api][:type] == 'json-rpc'
+ raise Msf::MCP::Metasploit::RpcStartupError,
+ 'RPC server is not running and auto-start is not supported for JSON-RPC API type.'
+ end
+
+ unless localhost?
+ message = "RPC server is not available at #{@config[:msf_api][:host]}:#{@config[:msf_api][:port]}."
+ message << ' Cannot auto-start RPC on remote hosts. Please start the RPC server manually.' if auto_start_enabled?
+ raise Msf::MCP::Metasploit::RpcStartupError, message
+ end
+
+ unless auto_start_enabled?
+ raise Msf::MCP::Metasploit::RpcStartupError,
+ "RPC server is not running on #{@config[:msf_api][:host]}:#{@config[:msf_api][:port]} " \
+ 'and auto-start is disabled.'
+ end
+
+ generate_random_credentials unless credentials_provided?
+ start_rpc_server
+ wait_for_rpc
+ end
+
+ private
+
+ # Absolute path to msfrpcd relative to the framework root.
+ MSFRPCD_PATH = File.join(__dir__, '../../../..', 'msfrpcd').freeze
+
+ # Build command-line arguments for msfrpcd.
+ #
+ # Note: credentials are passed via environment variables (MSF_RPC_USER,
+ # MSF_RPC_PASS) rather than command-line arguments for security.
+ #
+ # @return [Array]
+ def build_msfrpcd_args
+ args = ['-f'] # foreground mode
+ args.push('-a', @config[:msf_api][:host].to_s)
+ args.push('-p', @config[:msf_api][:port].to_s)
+ args.push('-S') if @config[:msf_api][:ssl] == false
+ args
+ end
+
+ # Check whether the host is a localhost address.
+ #
+ # @return [Boolean]
+ def localhost?
+ LOCALHOST_HOSTS.include?(@config[:msf_api][:host].to_s.downcase)
+ end
+
+ # Whether both user and password are present in the configuration.
+ #
+ # @return [Boolean]
+ def credentials_provided?
+ user = @config[:msf_api][:user]
+ password = @config[:msf_api][:password]
+ !user.to_s.strip.empty? && !password.to_s.strip.empty?
+ end
+
+ # Whether the BEARER token is present in the configuration.
+ #
+ # @return [Boolean]
+ def token_provided?
+ token = @config[:msf_api][:token]
+ !token.to_s.strip.empty?
+ end
+
+ # Verify that the caller has credentials to authenticate with an
+ # already-running RPC server. For MessagePack this means user+password;
+ # for JSON-RPC this means a bearer token.
+ #
+ # @raise [Msf::MCP::Metasploit::RpcStartupError] If required credentials
+ # are missing
+ def validate_credentials_for_existing_server!
+ if @config[:msf_api][:type] == 'json-rpc'
+ return if token_provided?
+
+ raise Msf::MCP::Metasploit::RpcStartupError,
+ 'RPC server is already running but no token was provided. ' \
+ 'Use --token option or MSF_API_TOKEN environment variable.'
+ else
+ return if credentials_provided?
+
+ raise Msf::MCP::Metasploit::RpcStartupError,
+ 'RPC server is already running but no credentials were provided. ' \
+ 'Use --user and --password options or MSF_API_USER and MSF_API_PASSWORD environment variables.'
+ end
+ end
+
+ # Generate random credentials and write them into the config hash.
+ #
+ # @return [void]
+ def generate_random_credentials
+ @config[:msf_api][:user] = SecureRandom.hex(8)
+ @config[:msf_api][:password] = SecureRandom.hex(16)
+ @output.puts 'Generated random credentials for auto-started RPC server'
+ ilog({ message: 'Generated random credentials for auto-started RPC server' },
+ LOG_SOURCE, LOG_INFO)
+ end
+
+ # Check if the managed child process is still alive.
+ # Raises RpcStartupError if it has exited.
+ def check_managed_process_alive!
+ return unless @rpc_pid
+
+ result = Process.waitpid(@rpc_pid, Process::WNOHANG)
+ return unless result
+
+ @rpc_pid = nil
+ @rpc_managed = false
+ raise Msf::MCP::Metasploit::RpcStartupError, 'RPC server process exited unexpectedly'
+ end
+
+ # Wait for the child process to exit after SIGTERM, escalating to
+ # SIGKILL if it does not exit within the grace period.
+ def graceful_wait
+ result = Process.waitpid(@rpc_pid, Process::WNOHANG)
+ return if result
+
+ sleep(STOP_GRACE_PERIOD)
+ result = Process.waitpid(@rpc_pid, Process::WNOHANG)
+ return if result
+
+ # Process did not exit; escalate to SIGKILL
+ Process.kill('KILL', @rpc_pid)
+ Process.waitpid(@rpc_pid, 0)
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/security/input_validator.rb b/lib/msf/core/mcp/security/input_validator.rb
new file mode 100644
index 0000000000000..7b047f52b2f9b
--- /dev/null
+++ b/lib/msf/core/mcp/security/input_validator.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+require 'ipaddr'
+
+module Msf::MCP
+ module Security
+ class InputValidator
+ LIMIT_DEFAULT = 100
+ LIMIT_MIN = 1
+ LIMIT_MAX = 1000
+
+ # Generic parameter validation against a constraint
+ #
+ # Dispatches based on the constraint type:
+ # - Array → value must be included in the list (enum)
+ # - Range → value must be an integer within the range, or a Range whose
+ # bounds are within the constraint (range must be integer-bounded)
+ # - Regexp → value (via .to_s) must match the pattern
+ #
+ # @param name [String] Parameter name (used in error messages)
+ # @param value [Object] Value to validate
+ # @param constraint [Array, Range, Regexp] Allowed values, range, or pattern
+ # @param allow_nil [Boolean] Whether nil/empty values are allowed (default: false)
+ # @param max_size [Integer] (optional) Maximum length for string values (only applies to Regexp constraints)
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_parameter!(name, value, constraint, allow_nil: false, max_size: nil)
+ if allow_nil
+ return true if value.nil?
+ return true if value.respond_to?(:empty?) && value.empty?
+ else
+ raise ValidationError, "#{name} cannot be nil" if value.nil?
+ raise ValidationError, "#{name} cannot be empty" if value.respond_to?(:empty?) && value.empty?
+ end
+
+ case constraint
+ when Array
+ unless constraint.include?(value)
+ raise ValidationError, "Invalid #{name}: #{value.inspect}. Must be one of: #{constraint.join(', ')}"
+ end
+ when Range
+ unless constraint.first.is_a?(Integer) && constraint.last.is_a?(Integer)
+ raise ArgumentError, "Range constraint must be a range of integers, got #{constraint.first.class}..#{constraint.last.class}"
+ end
+ if value.is_a?(Range)
+ begin
+ int_first = Integer(value.first)
+ int_last = Integer(value.last)
+ rescue TypeError, ArgumentError
+ raise ValidationError, "#{name} must have integer bounds: #{value.inspect}"
+ end
+ unless constraint.cover?(int_first..int_last)
+ raise ValidationError, "#{name} must be between #{constraint.min} and #{constraint.max}: #{int_first}..#{int_last}"
+ end
+ else
+ begin
+ int_value = Integer(value)
+ rescue TypeError, ArgumentError
+ raise ValidationError, "#{name} must be an integer: #{value.inspect}"
+ end
+ unless constraint.cover?(int_value)
+ raise ValidationError, "#{name} must be between #{constraint.min} and #{constraint.max}: #{value}"
+ end
+ end
+ when Regexp
+ string_value = value.to_s
+ if max_size && string_value.length > max_size
+ raise ValidationError, "#{name} too long (max #{max_size} characters)"
+ end
+ unless string_value.match?(constraint)
+ raise ValidationError, "Invalid #{name} format: #{value}"
+ end
+ else
+ raise ArgumentError, "Unsupported constraint type: #{constraint.class}"
+ end
+
+ true
+ end
+
+ # Validate IP address or CIDR range
+ #
+ # @param addr [String] IP address or CIDR (e.g., "192.168.1.1" or "192.168.1.0/24")
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_ip_address!(addr)
+ return true if addr.nil? || addr.empty?
+
+ begin
+ IPAddr.new(addr)
+ true
+ rescue IPAddr::InvalidAddressError
+ raise ValidationError, "Invalid IP address or CIDR: #{addr}"
+ end
+ end
+
+ # Validate port or port range
+ #
+ # @param range [String, Integer] Port number or range (e.g., "80" or "80-443")
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_port_range!(range)
+ return true if range.nil? || range.to_s.empty?
+
+ range_str = range.to_s
+
+ # Match a port range like "80-443" — requires digits on both sides of the dash
+ if range_str.match?(/\A\s*[[:alnum:]]+-[[:alnum:]]+\s*\z/)
+ begin
+ start_port, end_port = range_str.split('-', 2).map { |p| Integer(p.strip) }
+ rescue TypeError, ArgumentError
+ raise ValidationError, "Port range must have integer bounds: #{range_str}"
+ end
+ validate_parameter!('Port range', start_port..end_port, 1..65535)
+ else
+ validate_parameter!('Port', range_str, 1..65535)
+ end
+
+ true
+ end
+
+ # Validate query string for module search
+ #
+ # @param query [String] Search query
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_search_query!(query)
+ validate_parameter!('Search query', query, /\A[[:print:]]+\z/, allow_nil: false, max_size: 500)
+ end
+
+ # Validate limit parameter for pagination
+ #
+ # @param limit [Integer] Limit value
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_limit!(limit)
+ validate_parameter!('Limit', limit, LIMIT_MIN..LIMIT_MAX, allow_nil: true)
+ end
+
+ # Validate offset parameter for pagination
+ #
+ # @param offset [Integer] Offset value
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_offset!(offset)
+ validate_parameter!('Offset', offset, 0..LIMIT_MAX, allow_nil: true)
+ end
+
+ # Validate pagination parameters
+ #
+ # @param limit [Integer] Limit value
+ # @param offset [Integer] Offset value
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_pagination!(limit, offset)
+ validate_limit!(limit)
+ validate_offset!(offset)
+ end
+
+ # Validate module type
+ #
+ # @param module_type [String] Module type
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_module_type!(module_type)
+ validate_parameter!('Module type', module_type, %w[exploit auxiliary post payload encoder evasion nop])
+ end
+
+ # Validate module name
+ #
+ # @param module_name [String] Module name/path
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_module_name!(module_name)
+ # Basic path validation (alphanumeric, slashes, underscores, hyphens)
+ validate_parameter!('Module name', module_name, %r{\A[\w/\-]+\z}, max_size: 500)
+ end
+
+ # Validate only_up boolean parameter
+ #
+ # @param only_up [Boolean] Only up parameter
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_only_up!(only_up)
+ validate_parameter!('only_up', only_up, [true, false])
+ end
+
+ # Validate protocol parameter
+ #
+ # @param protocol [String] Protocol ('tcp' or 'udp')
+ # @return [true] If valid
+ # @raise [ValidationError] If invalid
+ def self.validate_protocol!(protocol)
+ validate_parameter!('Protocol', protocol.to_s.downcase, %w[tcp udp], allow_nil: true)
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/security/rate_limiter.rb b/lib/msf/core/mcp/security/rate_limiter.rb
new file mode 100644
index 0000000000000..37dc4801cfc40
--- /dev/null
+++ b/lib/msf/core/mcp/security/rate_limiter.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Security
+ class RateLimiter
+ #attr_reader :requests_per_minute, :burst_size
+
+ # Initialize rate limiter with token bucket algorithm
+ #
+ # @param requests_per_minute [Integer] Maximum requests per minute
+ # @param burst_size [Integer] Maximum burst size (default: same as requests_per_minute)
+ def initialize(requests_per_minute: 60, burst_size: nil)
+ @requests_per_minute = requests_per_minute
+ @burst_size = burst_size || requests_per_minute
+ @tokens = @burst_size.to_f
+ @last_refill = Time.now
+ @mutex = Mutex.new
+ end
+
+ # Check if request is allowed, consume token if yes
+ #
+ # @param tool_name [String, nil] Tool name (for logging/tracking)
+ # @return [Integer] Number of tokens,if request allowed
+ # @raise [RateLimitExceededError] If rate limit exceeded
+ def check_rate_limit!(tool_name = nil)
+ @mutex.synchronize do
+ refill!
+
+ if @tokens >= 1.0
+ @tokens -= 1.0
+ else
+ # Calculate retry_after in seconds
+ tokens_per_second = @requests_per_minute / 60.0
+ retry_after = ((1.0 - @tokens) / tokens_per_second).ceil
+
+ raise RateLimitExceededError.new(retry_after)
+ end
+ end
+ end
+
+ private
+
+ # Refill tokens based on elapsed time
+ def refill!
+ now = Time.now
+ elapsed = now - @last_refill
+
+ # Calculate tokens to add based on elapsed time
+ tokens_per_second = @requests_per_minute / 60.0
+ tokens_to_add = elapsed * tokens_per_second
+
+ # Add tokens but cap at burst_size
+ @tokens = [@tokens + tokens_to_add, @burst_size.to_f].min
+ @last_refill = now
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/server.rb b/lib/msf/core/mcp/server.rb
new file mode 100644
index 0000000000000..6d80a413fce68
--- /dev/null
+++ b/lib/msf/core/mcp/server.rb
@@ -0,0 +1,242 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ ##
+ # MCP Server Wrapper for Metasploit Framework
+ #
+ # This class initializes and manages the MCP server with all registered tools.
+ # It provides a clean interface for starting/stopping the server and integrates
+ # with the Metasploit client and security layers.
+ #
+ # The Server expects fully configured and authenticated dependencies to be
+ # provided during initialization. It does not handle configuration loading
+ # or client authentication - those are responsibilities of the calling code.
+ #
+ class Server
+
+ ##
+ # Initialize the MCP server with required dependencies
+ #
+ # @param msf_client [Metasploit::Client] Configured and authenticated Metasploit client
+ # @param rate_limiter [Security::RateLimiter] Configured rate limiter
+ #
+ def initialize(msf_client:, rate_limiter:)
+ @msf_client = msf_client
+
+ # Create server context (passed to all tool calls)
+ # Tools only need msf_client and rate_limiter
+ @server_context = {
+ msf_client: @msf_client,
+ rate_limiter: rate_limiter
+ }
+
+ # Create MCP configuration with request lifecycle callbacks
+ mcp_config = ::MCP::Configuration.new
+ mcp_config.around_request = create_around_request
+ mcp_config.exception_reporter = create_exception_reporter
+
+ # Initialize MCP server with all tools
+ @mcp_server = ::MCP::Server.new(
+ name: 'msfmcp',
+ version: Msf::MCP::Application::VERSION,
+ tools: [
+ Tools::SearchModules,
+ Tools::ModuleInfo,
+ Tools::HostInfo,
+ Tools::ServiceInfo,
+ Tools::VulnerabilityInfo,
+ Tools::NoteInfo,
+ Tools::CredentialInfo,
+ Tools::LootInfo
+ ],
+ server_context: @server_context,
+ configuration: mcp_config
+ )
+ end
+
+ ##
+ # Start the MCP server with specified transport
+ #
+ # @param transport [Symbol] Transport type (:stdio or :http)
+ # @param host [String] Host address for HTTP transport (default: 'localhost')
+ # @param port [Integer] Port number for HTTP transport (default: 3000)
+ #
+ # @return [MCP::Server] The MCP server instance (for testing purposes)
+ # @raise [ArgumentError] If an unknown transport is specified
+ #
+ def start(transport: :stdio, host: 'localhost', port: 3000)
+ case transport
+ when :stdio
+ start_stdio
+ when :http
+ start_http(host, port)
+ else
+ raise ArgumentError, "Unknown transport: #{transport}. Use :stdio or :http"
+ end
+ end
+
+ ##
+ # Shutdown the MCP server and cleanup resources
+ #
+ def shutdown
+ @msf_client&.shutdown
+ @mcp_server = nil
+ end
+
+ private
+
+ ##
+ # Start stdio transport (for CLI usage)
+ #
+ # @return [MCP::Server] The MCP server instance (for testing purposes)
+ #
+ def start_stdio
+ transport = ::MCP::Server::Transports::StdioTransport.new(@mcp_server)
+ transport.open
+ @mcp_server
+ end
+
+ ##
+ # Start HTTP transport (for web/network usage)
+ #
+ # The transport implements the Rack app interface (#call), so it is mounted
+ # directly. MCP-aware request/response logging is handled by the
+ # Middleware::RequestLogger middleware.
+ #
+ # @param host [String] Host address to bind to
+ # @param port [Integer] Port to listen on
+ #
+ # @return [MCP::Server] The MCP server instance (for testing purposes)
+ #
+ def start_http(host, port)
+ require 'rack'
+ require 'rack/handler/puma'
+
+ transport = ::MCP::Server::Transports::StreamableHTTPTransport.new(@mcp_server)
+
+ # Build the Rack application with logging middleware.
+ # The transport itself is a Rack app (implements #call).
+ rack_app = Rack::Builder.new do
+ use Msf::MCP::Middleware::RequestLogger
+ run transport
+ end
+
+ # Start Puma server using the handler appropriate for the Rack version.
+ # Rackup::Handler is available with rackup >= 2.x / Rack 3+;
+ # Rack::Handler is used with Rack < 3 and rackup 1.x.
+ puma_handler = if defined?(Rackup::Handler)
+ Rackup::Handler::Puma
+ else
+ Rack::Handler::Puma
+ end
+ puma_handler.run(
+ rack_app,
+ Port: port,
+ Host: host,
+ Silent: true
+ )
+
+ @mcp_server
+ end
+
+ ##
+ # Create around_request callback for MCP SDK
+ #
+ # This callback wraps every JSON-RPC request handler, providing access to
+ # both the instrumentation data and the response result. It replaces the
+ # deprecated +instrumentation_callback+ which only fires after completion
+ # and does not expose the result.
+ #
+ # The +data+ hash is populated by the SDK with:
+ # - :method — the JSON-RPC method name (e.g. "tools/call", "tools/list")
+ # - :tool_name, :prompt_name, :resource_uri — specific handler identifiers
+ # - :tool_arguments — arguments passed to a tool call
+ # - :client — client info hash (name, version)
+ # - :error — error type symbol (e.g. :tool_not_found, :internal_error)
+ # - :duration — added in the ensure block after this callback returns
+ #
+ # @return [Proc] Callback that wraps request execution and logs via Rex
+ #
+ def create_around_request
+ ->(data, &request_handler) do
+ result = request_handler.call
+
+ # Build message based on the type of request
+ message = if data[:error]
+ "MCP Error: #{data[:error]}"
+ elsif data[:tool_name]
+ "Tool call: #{data[:tool_name]}"
+ elsif data[:prompt_name]
+ "Prompt call: #{data[:prompt_name]}"
+ elsif data[:resource_uri]
+ "Resource call: #{data[:resource_uri]}"
+ elsif data[:method]
+ "Method call: #{data[:method]}"
+ else
+ "MCP request"
+ end
+
+ context = data.dup
+ if result
+ message = "#{message} (ERROR)" if result[:isError]
+ context[:result] = result
+ end
+
+ if data[:error] || result&.fetch(:isError, nil)
+ elog({ message: message, context: context }, LOG_SOURCE, LOG_ERROR)
+ else
+ ilog({ message: message, context: context }, LOG_SOURCE, LOG_INFO)
+ end
+
+ result
+ end
+ end
+
+ ##
+ # Create exception reporter callback for MCP SDK
+ #
+ # This callback is invoked for any server exception during request processing,
+ # which are not tool execution errors.
+ # It receives:
+ # - exception: The Ruby exception object
+ # - context: Hash with :request (JSON string) or :notification (method name string)
+ #
+ # @return [Proc] Callback that logs exceptions via Rex
+ #
+ def create_exception_reporter
+ ->(exception, context) do
+ return unless exception || context
+
+ # Determine the context type and parse data
+ error_context = {}
+
+ if context&.fetch(:request, nil)
+ error_context[:type] = 'request'
+ request = nil
+ begin
+ request = JSON.parse(context[:request])
+ rescue JSON::ParserError
+ # Not valid JSON, log raw data
+ error_context[:raw_data] = context[:request].inspect
+ else
+ error_context[:method] = request['method'] if request['method']
+ error_context[:params] = request['params'] if request['params']
+ end
+ elsif context&.fetch(:notification, nil)
+ error_context[:type] = 'notification'
+ # context[:notification] is the notification method name (string)
+ error_context[:method] = context[:notification]
+ else
+ error_context[:type] = 'unknown'
+ error_context[:raw_data] = context.inspect
+ end
+
+ elog({
+ message: "Error during #{error_context[:type]} processing#{error_context[:method] ? " (#{error_context[:method]})" : ''}",
+ exception: exception,
+ context: error_context
+ }, LOG_SOURCE, LOG_ERROR)
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/credential_info.rb b/lib/msf/core/mcp/tools/credential_info.rb
new file mode 100644
index 0000000000000..9d003bab7ec64
--- /dev/null
+++ b/lib/msf/core/mcp/tools/credential_info.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # MCP Tool: Query Metasploit Database Credentials
+ #
+ # Retrieves credential information from the Metasploit database including
+ # usernames, password hashes, and authentication data.
+ #
+ class CredentialInfo < ::MCP::Tool
+ tool_name 'msf_credential_info'
+ description 'Query Metasploit database for discovered credentials. '\
+ 'Returns credential information including usernames and password data.'
+
+ input_schema(
+ properties: {
+ workspace: {
+ type: 'string',
+ description: 'Workspace name (default: "default")',
+ default: 'default'
+ },
+ limit: {
+ type: 'integer',
+ description: 'Maximum number of results',
+ minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN,
+ maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX,
+ default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT
+ },
+ offset: {
+ type: 'integer',
+ description: 'Number of results to skip',
+ minimum: 0,
+ default: 0
+ }
+ },
+ required: [:workspace]
+ )
+
+ output_schema(
+ properties: {
+ metadata: {
+ properties: {
+ workspace: { type: 'string' },
+ query_time: { type: 'number' },
+ total_items: { type: 'integer' },
+ returned_items: { type: 'integer' },
+ limit: { type: 'integer' },
+ offset: { type: 'integer' }
+ }
+ },
+ data: {
+ type: 'array',
+ items: {
+ properties: {
+ host: { type: 'string' },
+ port: { type: 'integer' },
+ protocol: { type: 'string' },
+ service_name: { type: 'string' },
+ user: { type: 'string' },
+ secret: { type: 'string' },
+ type: { type: 'string' },
+ updated_at: { type: 'string' }
+ }
+ }
+ }
+ },
+ required: [:metadata, :data]
+ )
+
+ annotations(
+ read_only_hint: true,
+ idempotent_hint: true,
+ destructive_hint: false
+ )
+
+ meta({ source: 'metasploit_database' })
+
+ class << self
+ include ToolHelper
+
+ ##
+ # Execute credential query with secure memory handling
+ #
+ # @param workspace [String] Workspace name (default: 'default')
+ # @param limit [Integer] Maximum results (default: 100)
+ # @param offset [Integer] Results offset (default: 0)
+ # @param server_context [Hash] Server context with msf_client, rate_limiter, config
+ # @return [MCP::Tool::Response] Structured response with credential information
+ #
+ def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:)
+ start_time = Time.now
+
+ # Extract dependencies from server context
+ msf_client = server_context[:msf_client]
+ rate_limiter = server_context[:rate_limiter]
+
+ # Check rate limit
+ rate_limiter.check_rate_limit!('credential_info')
+
+ # Validate inputs
+ Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset)
+
+ # Call Metasploit API
+ # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided.
+ # The default value is sent anyway for clarity.
+ options = { workspace: workspace }
+ raw_creds = msf_client.db_creds(options)
+
+ # Transform response
+ transformed = Metasploit::ResponseTransformer.transform_creds(raw_creds)
+
+ # Apply pagination
+ #
+ # Note that to get the total number of entries, we gather the entire data set and apply pagination here
+ # instead of sending the limit and offset to the API call to be processed by MSF.
+ # This is needed to provide accurate total_items count in the metadata.
+ total_items = transformed.size
+ paginated_data = transformed[offset, limit] || []
+
+ # Build metadata
+ metadata = {
+ workspace: workspace,
+ query_time: (Time.now - start_time).round(3),
+ total_items: total_items,
+ returned_items: paginated_data.size,
+ limit: limit,
+ offset: offset
+ }
+
+ # Return MCP response
+ ::MCP::Tool::Response.new(
+ [
+ {
+ type: 'text',
+ text: JSON.generate(
+ metadata: metadata,
+ data: paginated_data
+ )
+ }
+ ],
+ structured_content: {
+ metadata: metadata,
+ data: paginated_data
+ }
+ )
+ rescue Msf::MCP::Security::RateLimitExceededError => e
+ tool_error_response("Rate limit exceeded: #{e.message}")
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ tool_error_response("Authentication failed: #{e.message}")
+ rescue Msf::MCP::Metasploit::APIError => e
+ tool_error_response("Metasploit API error: #{e.message}")
+ rescue Msf::MCP::Security::ValidationError => e
+ tool_error_response(e.message)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/host_info.rb b/lib/msf/core/mcp/tools/host_info.rb
new file mode 100644
index 0000000000000..cead5a9af0739
--- /dev/null
+++ b/lib/msf/core/mcp/tools/host_info.rb
@@ -0,0 +1,177 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # MCP Tool: Query Metasploit Database Hosts
+ #
+ # Retrieves host information from the Metasploit database including
+ # IP addresses, operating systems, and discovery metadata.
+ #
+ class HostInfo < ::MCP::Tool
+ tool_name 'msf_host_info'
+ description 'Query Metasploit database for discovered hosts. '\
+ 'Returns host information including IP, OS, MAC address, and metadata.'
+
+ input_schema(
+ properties: {
+ workspace: {
+ type: 'string',
+ description: 'Workspace name (default: "default")',
+ default: 'default'
+ },
+ addresses: {
+ type: 'string',
+ description: 'IP address or CIDR range to filter (e.g., "192.168.1.100" or "192.168.1.0/24")'
+ },
+ only_up: {
+ type: 'boolean',
+ description: 'Filter to only return hosts that are up',
+ default: false
+ },
+ limit: {
+ type: 'integer',
+ description: 'Maximum number of results',
+ minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN,
+ maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX,
+ default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT
+ },
+ offset: {
+ type: 'integer',
+ description: 'Number of results to skip',
+ minimum: 0,
+ default: 0
+ }
+ }
+ )
+
+ output_schema(
+ properties: {
+ metadata: {
+ properties: {
+ workspace: { type: 'string' },
+ query_time: { type: 'number' },
+ total_items: { type: 'integer' },
+ returned_items: { type: 'integer' },
+ limit: { type: 'integer' },
+ offset: { type: 'integer' }
+ }
+ },
+ data: {
+ type: 'array',
+ items: {
+ properties: {
+ created_at: { type: 'string' },
+ address: { type: 'string' },
+ mac_address: { type: 'string' },
+ hostname: { type: 'string' },
+ state: { type: 'string' },
+ os_name: { type: 'string' },
+ os_flavor: { type: 'string' },
+ os_service_pack: { type: 'string' },
+ os_language: { type: 'string' },
+ updated_at: { type: 'string' },
+ purpose: { type: 'string' },
+ info: { type: 'string' }
+ }
+ }
+ }
+ },
+ required: [:metadata, :data]
+ )
+
+ annotations(
+ read_only_hint: true,
+ idempotent_hint: true,
+ destructive_hint: false
+ )
+
+ meta({ source: 'metasploit_database' })
+
+ class << self
+ include ToolHelper
+
+ ##
+ # Execute host query
+ #
+ # @param workspace [String] Workspace name (default: 'default')
+ # @param addresses [String, nil] IP address or CIDR range to filter
+ # @param only_up [Boolean] Filter to only return hosts that are up
+ # @param limit [Integer] Maximum results (default: 100)
+ # @param offset [Integer] Results offset (default: 0)
+ # @param server_context [Hash] Server context with msf_client, rate_limiter, config
+ # @return [MCP::Tool::Response] Structured response with host information
+ #
+ def call(workspace: 'default', addresses: nil, only_up: false, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:)
+ start_time = Time.now
+
+ # Extract dependencies from server context
+ msf_client = server_context[:msf_client]
+ rate_limiter = server_context[:rate_limiter]
+
+ # Check rate limit
+ rate_limiter.check_rate_limit!('host_info')
+
+ # Validate inputs
+ Msf::MCP::Security::InputValidator.validate_only_up!(only_up)
+ Msf::MCP::Security::InputValidator.validate_ip_address!(addresses) if addresses
+ Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset)
+
+ # Call Metasploit API
+ # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided.
+ # The default value is sent anyway for clarity.
+ options = { workspace: workspace }
+ options[:addresses] = addresses if addresses
+ options[:only_up] = only_up if only_up
+ raw_hosts = msf_client.db_hosts(options)
+
+ # Transform response
+ transformed = Metasploit::ResponseTransformer.transform_hosts(raw_hosts)
+
+ # Apply pagination
+ #
+ # Note that to get the total number of entries, we gather the entire data set and apply pagination here
+ # instead of sending the limit and offset to the API call to be processed by MSF.
+ # This is needed to provide accurate total_items count in the metadata.
+ total_items = transformed.size
+ paginated_data = transformed[offset, limit] || []
+
+ # Build metadata
+ metadata = {
+ workspace: workspace,
+ query_time: (Time.now - start_time).round(3),
+ total_items: total_items,
+ returned_items: paginated_data.size,
+ limit: limit,
+ offset: offset
+ }
+
+ # Return MCP response
+ ::MCP::Tool::Response.new(
+ [
+ {
+ type: 'text',
+ text: JSON.generate(
+ metadata: metadata,
+ data: paginated_data
+ )
+ }
+ ],
+ structured_content: {
+ metadata: metadata,
+ data: paginated_data
+ }
+ )
+ rescue Msf::MCP::Security::RateLimitExceededError => e
+ tool_error_response("Rate limit exceeded: #{e.message}")
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ tool_error_response("Authentication failed: #{e.message}")
+ rescue Msf::MCP::Metasploit::APIError => e
+ tool_error_response("Metasploit API error: #{e.message}")
+ rescue Msf::MCP::Security::ValidationError => e
+ tool_error_response(e.message)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/loot_info.rb b/lib/msf/core/mcp/tools/loot_info.rb
new file mode 100644
index 0000000000000..62a56e29058f0
--- /dev/null
+++ b/lib/msf/core/mcp/tools/loot_info.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # MCP Tool: Query Metasploit Database Loot
+ #
+ # Retrieves loot information from the Metasploit database including
+ # collected files, data, and artifacts from compromised systems.
+ #
+ class LootInfo < ::MCP::Tool
+ tool_name 'msf_loot_info'
+ description 'Query Metasploit database for collected loot. '\
+ 'Returns loot information including file paths and content types.'
+
+ input_schema(
+
+ properties: {
+ workspace: {
+ type: 'string',
+ description: 'Workspace name (default: "default")',
+ default: 'default'
+ },
+ limit: {
+ type: 'integer',
+ description: 'Maximum number of results',
+ minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN,
+ maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX,
+ default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT
+ },
+ offset: {
+ type: 'integer',
+ description: 'Number of results to skip',
+ minimum: 0,
+ default: 0
+ }
+ },
+ required: [:workspace]
+ )
+
+ output_schema(
+ properties: {
+ metadata: {
+ properties: {
+ workspace: { type: 'string' },
+ query_time: { type: 'number' },
+ total_items: { type: 'integer' },
+ returned_items: { type: 'integer' },
+ limit: { type: 'integer' },
+ offset: { type: 'integer' }
+ }
+ },
+ data: {
+ type: 'array',
+ items: {
+ properties: {
+ host: { type: 'string' },
+ service_name_or_port: { type: 'string' },
+ loot_type: { type: 'string' },
+ content_type: { type: 'string' },
+ name: { type: 'string' },
+ info: { type: 'string' },
+ data: { type: 'string' },
+ created_at: { type: 'string' },
+ updated_at: { type: 'string' }
+ }
+ }
+ }
+ },
+ required: [:metadata, :data]
+ )
+
+ annotations(
+ read_only_hint: true,
+ idempotent_hint: true,
+ destructive_hint: false
+ )
+
+ meta({ source: 'metasploit_database' })
+
+ class << self
+ include ToolHelper
+
+ ##
+ # Execute loot query
+ #
+ # @param workspace [String] Workspace name (default: 'default')
+ # @param limit [Integer] Maximum results (default: 100)
+ # @param offset [Integer] Results offset (default: 0)
+ # @param server_context [Hash] Server context with msf_client, rate_limiter, config
+ # @return [MCP::Tool::Response] Structured response with loot information
+ #
+ def call(workspace: 'default', limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:)
+ start_time = Time.now
+
+ # Extract dependencies from server context
+ msf_client = server_context[:msf_client]
+ rate_limiter = server_context[:rate_limiter]
+
+ # Check rate limit
+ rate_limiter.check_rate_limit!('loot_info')
+
+ # Validate inputs
+ Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset)
+
+ # Call Metasploit API
+ # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided.
+ # The default value is sent anyway for clarity.
+ options = { workspace: workspace }
+ raw_loot = msf_client.db_loot(options)
+
+ # Transform response
+ transformed = Metasploit::ResponseTransformer.transform_loot(raw_loot)
+
+ # Apply pagination
+ #
+ # Note that to get the total number of entries, we gather the entire data set and apply pagination here
+ # instead of sending the limit and offset to the API call to be processed by MSF.
+ # This is needed to provide accurate total_items count in the metadata.
+ total_items = transformed.size
+ paginated_data = transformed[offset, limit] || []
+
+ # Build metadata
+ metadata = {
+ workspace: workspace,
+ query_time: (Time.now - start_time).round(3),
+ total_items: total_items,
+ returned_items: paginated_data.size,
+ limit: limit,
+ offset: offset
+ }
+
+ # Return MCP response
+ ::MCP::Tool::Response.new(
+ [
+ {
+ type: 'text',
+ text: JSON.generate(
+ metadata: metadata,
+ data: paginated_data
+ )
+ }
+ ],
+ structured_content: {
+ metadata: metadata,
+ data: paginated_data
+ }
+ )
+ rescue Msf::MCP::Security::RateLimitExceededError => e
+ tool_error_response("Rate limit exceeded: #{e.message}")
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ tool_error_response("Authentication failed: #{e.message}")
+ rescue Msf::MCP::Metasploit::APIError => e
+ tool_error_response("Metasploit API error: #{e.message}")
+ rescue Msf::MCP::Security::ValidationError => e
+ tool_error_response(e.message)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/module_info.rb b/lib/msf/core/mcp/tools/module_info.rb
new file mode 100644
index 0000000000000..de2bcacf7be6c
--- /dev/null
+++ b/lib/msf/core/mcp/tools/module_info.rb
@@ -0,0 +1,147 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # MCP Tool: Get Metasploit Module Information
+ #
+ # Retrieves detailed information about a specific Metasploit module including
+ # options, targets, references, and compatibility details.
+ #
+ class ModuleInfo < ::MCP::Tool
+ tool_name 'msf_module_info'
+ description 'Retrieves detailed information, documentation, and options for a single specific Metasploit module. '\
+ 'Returns comprehensive module details including options, targets, payloads, and references.'
+
+ input_schema(
+ properties: {
+ type: {
+ type: 'string',
+ description: 'Module type (exploit, auxiliary, post, payload, etc.)',
+ enum: ['exploit', 'auxiliary', 'post', 'payload', 'encoder', 'evasion', 'nop']
+ },
+ name: {
+ type: 'string',
+ description: 'Module path/name (e.g., windows/smb/ms17_010_eternalblue)',
+ minLength: 1,
+ maxLength: 500
+ }
+ },
+ required: [:type, :name]
+ )
+
+ output_schema(
+ properties: {
+ metadata: {
+ properties: {
+ query_time: { type: 'number' }
+ }
+ },
+ data: {
+ properties: {
+ # TODO: consider adding `description` fields to these properties
+ type: { type: 'string' },
+ name: { type: 'string' },
+ fullname: { type: 'string' },
+ rank: { type: 'string' },
+ disclosure_date: { type: 'string' },
+ description: { type: 'string' },
+ license: { type: 'string' },
+ filepath: { type: 'string' },
+ architectures: { type: 'array', items: { type: 'string', enum: %w[
+ x86 x86_64 x64 mips mipsle mipsbe mips64 mips64le ppc ppce500v2
+ ppc64 ppc64le cbea cbea64 sparc sparc64 armle armbe aarch64 cmd
+ php tty java ruby dalvik python nodejs firefox zarch r
+ riscv32be riscv32le riscv64be riscv64le loongarch64
+ ] } },
+ platforms: { type: 'array', items: { type: 'string' } },
+ authors: { type: 'array', items: { type: 'string' } },
+ privileged: { type: 'boolean' },
+ has_check_method: { type: 'boolean' },
+ default_options: { type: 'object' },
+ references: { type: 'array', items: { type: ['string', 'object'] } },
+ targets: { type: 'object' },
+ default_target: { type: 'integer' },
+ stance: { type: 'string' },
+ actions: { type: 'object' },
+ default_action: { type: 'integer' },
+ options: { type: 'object' }
+ }
+ }
+ },
+ required: [:metadata, :data]
+ )
+
+ annotations(
+ read_only_hint: true,
+ idempotent_hint: true,
+ destructive_hint: false
+ )
+
+ meta({ source: 'metasploit_framework' })
+
+ class << self
+ include ToolHelper
+
+ ##
+ # Execute module info retrieval
+ #
+ # @param type [String] Type of module
+ # @param name [String] Name/path of module
+ # @param server_context [Hash] Server context with msf_client, rate_limiter, config
+ # @return [MCP::Tool::Response] Structured response with module details
+ #
+ def call(type:, name:, server_context:)
+ start_time = Time.now
+
+ # Extract dependencies from server context
+ msf_client = server_context[:msf_client]
+ rate_limiter = server_context[:rate_limiter]
+
+ # Check rate limit
+ rate_limiter.check_rate_limit!('module_info')
+
+ # Validate inputs
+ Msf::MCP::Security::InputValidator.validate_module_type!(type)
+ Msf::MCP::Security::InputValidator.validate_module_name!(name)
+
+ # Call Metasploit API
+ raw_module_info = msf_client.module_info(type, name)
+
+ # Transform response
+ transformed = Metasploit::ResponseTransformer.transform_module_info(raw_module_info)
+
+ # Build metadata
+ metadata = {
+ query_time: (Time.now - start_time).round(3)
+ }
+
+ # Return MCP response
+ ::MCP::Tool::Response.new(
+ [
+ {
+ type: 'text',
+ text: JSON.generate(
+ metadata: metadata,
+ data: transformed
+ )
+ }
+ ],
+ structured_content: {
+ metadata: metadata,
+ data: transformed
+ }
+ )
+ rescue Msf::MCP::Security::RateLimitExceededError => e
+ tool_error_response("Rate limit exceeded: #{e.message}")
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ tool_error_response("Authentication failed: #{e.message}")
+ rescue Msf::MCP::Metasploit::APIError => e
+ tool_error_response("Metasploit API error: #{e.message}")
+ rescue Msf::MCP::Security::ValidationError => e
+ tool_error_response(e.message)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/note_info.rb b/lib/msf/core/mcp/tools/note_info.rb
new file mode 100644
index 0000000000000..776288e6dedbc
--- /dev/null
+++ b/lib/msf/core/mcp/tools/note_info.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # MCP Tool: Query Metasploit Database Notes
+ #
+ # Retrieves notes from the Metasploit database including user annotations,
+ # scan results, and discovery metadata.
+ #
+ class NoteInfo < ::MCP::Tool
+ tool_name 'msf_note_info'
+ description 'Query Metasploit database for notes and annotations. '\
+ 'Returns notes including host associations and metadata.'
+
+ input_schema(
+
+ properties: {
+ workspace: {
+ type: 'string',
+ description: 'Workspace name (default: "default")',
+ default: 'default'
+ },
+ type: {
+ type: 'string',
+ description: 'Note type (e.g. "ssl.certificate", "smb.fingerprint")'
+ },
+ host: {
+ type: 'string',
+ description: 'Host IP address to filter (e.g., "192.168.1.100")'
+ },
+ ports: {
+ type: 'string',
+ description: 'Port number or range to filter (e.g., "80" or "80-443")'
+ },
+ protocol: {
+ type: 'string',
+ description: 'Protocol to filter (tcp or udp)',
+ enum: ['tcp', 'udp']
+ },
+ limit: {
+ type: 'integer',
+ description: 'Maximum number of results',
+ minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN,
+ maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX,
+ default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT
+ },
+ offset: {
+ type: 'integer',
+ description: 'Number of results to skip',
+ minimum: 0,
+ default: 0
+ }
+ },
+ required: [:workspace]
+ )
+
+ output_schema(
+ properties: {
+ metadata: {
+ properties: {
+ workspace: { type: 'string' },
+ query_time: { type: 'number' },
+ total_items: { type: 'integer' },
+ returned_items: { type: 'integer' },
+ limit: { type: 'integer' },
+ offset: { type: 'integer' }
+ }
+ },
+ data: {
+ type: 'array',
+ items: {
+ properties: {
+ host: { type: 'string' },
+ service_name_or_port: { type: 'string' },
+ note_type: { type: 'string' },
+ data: { type: 'string' },
+ created_at: { type: 'string' }
+ }
+ }
+ }
+ },
+ required: [:metadata, :data]
+ )
+
+ annotations(
+ read_only_hint: true,
+ idempotent_hint: true,
+ destructive_hint: false
+ )
+
+ meta({ source: 'metasploit_database' })
+
+ class << self
+ include ToolHelper
+
+ ##
+ # Execute note query
+ #
+ # @param workspace [String] Workspace name (default: 'default')
+ # @param host [String, nil] Host IP address to filter
+ # @param type [String, nil] Note type to filter
+ # @param ports [String, nil] Port or port range to filter
+ # @param protocol [String, nil] Protocol to filter (tcp or udp)
+ # @param limit [Integer] Maximum results (default: 100)
+ # @param offset [Integer] Results offset (default: 0)
+ # @param server_context [Hash] Server context with msf_client, rate_limiter, config
+ # @return [MCP::Tool::Response] Structured response with note information
+ #
+ def call(workspace: 'default', host: nil, type: nil, ports: nil, protocol: nil, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:)
+ start_time = Time.now
+
+ # Extract dependencies from server context
+ msf_client = server_context[:msf_client]
+ rate_limiter = server_context[:rate_limiter]
+
+ # Check rate limit
+ rate_limiter.check_rate_limit!('note_info')
+
+ # Validate inputs
+ Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset)
+ Msf::MCP::Security::InputValidator.validate_protocol!(protocol) if protocol
+ Msf::MCP::Security::InputValidator.validate_ip_address!(host) if host
+ Msf::MCP::Security::InputValidator.validate_port_range!(ports) if ports
+
+ # Call Metasploit API
+ # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided.
+ # The default value is sent anyway for clarity.
+ options = { workspace: workspace }
+ options[:address] = host if host
+ options[:ntype] = type if type
+ options[:ports] = ports if ports
+ options[:proto] = protocol if protocol
+ raw_notes = msf_client.db_notes(options)
+
+ # Transform response
+ transformed = Metasploit::ResponseTransformer.transform_notes(raw_notes)
+
+ # Apply pagination
+ #
+ # Note that to get the total number of entries, we gather the entire data set and apply pagination here
+ # instead of sending the limit and offset to the API call to be processed by MSF.
+ # This is needed to provide accurate total_items count in the metadata.
+ total_items = transformed.size
+ paginated_data = transformed[offset, limit] || []
+
+ # Build metadata
+ metadata = {
+ workspace: workspace,
+ query_time: (Time.now - start_time).round(3),
+ total_items: total_items,
+ returned_items: paginated_data.size,
+ limit: limit,
+ offset: offset
+ }
+
+ # Return MCP response
+ ::MCP::Tool::Response.new(
+ [
+ {
+ type: 'text',
+ text: JSON.generate(
+ metadata: metadata,
+ data: paginated_data
+ )
+ }
+ ],
+ structured_content: {
+ metadata: metadata,
+ data: paginated_data
+ }
+ )
+ rescue Msf::MCP::Security::RateLimitExceededError => e
+ tool_error_response("Rate limit exceeded: #{e.message}")
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ tool_error_response("Authentication failed: #{e.message}")
+ rescue Msf::MCP::Metasploit::APIError => e
+ tool_error_response("Metasploit API error: #{e.message}")
+ rescue Msf::MCP::Security::ValidationError => e
+ tool_error_response(e.message)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/search_modules.rb b/lib/msf/core/mcp/tools/search_modules.rb
new file mode 100644
index 0000000000000..2af666efbb3f0
--- /dev/null
+++ b/lib/msf/core/mcp/tools/search_modules.rb
@@ -0,0 +1,158 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # MCP Tool: Search Metasploit Modules
+ #
+ # Searches the Metasploit Framework module database using various criteria.
+ # Supports keyword search, filtering by type, platform, and pagination.
+ #
+ class SearchModules < ::MCP::Tool
+ tool_name 'msf_search_modules'
+ description 'Search Metasploit modules according to generic search terms or specific criteria. '\
+ 'Returns a list of modules matching the search criteria.'
+
+ input_schema(
+ properties: {
+ # TODO: improve search criteria by adding the supported key/value pair.
+ # The API support things like `type:exploit platform:windows cve:CVE-2021-34527`
+ # Maybe adding specific fields for type, platform, cve, etc.
+ query: {
+ type: 'string',
+ description: 'Search query (keywords, module names, or CVE IDs)',
+ minLength: 1,
+ maxLength: 500
+ },
+ limit: {
+ type: 'integer',
+ description: 'Maximum number of results to return',
+ minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN,
+ maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX,
+ default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT
+ },
+ offset: {
+ type: 'integer',
+ description: 'Number of results to skip (for pagination)',
+ minimum: 0,
+ default: 0
+ }
+ },
+ required: [:query]
+ )
+
+ output_schema(
+ properties: {
+ metadata: {
+ properties: {
+ query: { type: 'string' },
+ query_time: { type: 'number' },
+ total_items: { type: 'integer' },
+ returned_items: { type: 'integer' },
+ limit: { type: 'integer' },
+ offset: { type: 'integer' }
+ }
+ },
+ data: {
+ type: 'array',
+ items: {
+ properties: {
+ fullname: { type: 'string' },
+ type: { type: 'string' },
+ name: { type: 'string' },
+ rank: { type: 'string' },
+ disclosure_date: { type: 'string' }
+ }
+ }
+ }
+ },
+ required: [:metadata, :data]
+ )
+
+ annotations(
+ read_only_hint: true,
+ idempotent_hint: true,
+ destructive_hint: false
+ )
+
+ meta({ source: 'metasploit_framework' })
+
+ class << self
+ include ToolHelper
+
+ ##
+ # Execute module search
+ #
+ # @param query [String] Search query
+ # @param limit [Integer] Maximum results (default: 100)
+ # @param offset [Integer] Results offset (default: 0)
+ # @param server_context [Hash] Server context with msf_client, rate_limiter, config
+ # @return [MCP::Tool::Response] Structured response with search results
+ #
+ def call(query:, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:)
+ start_time = Time.now
+
+ # Extract dependencies from server context
+ msf_client = server_context[:msf_client]
+ rate_limiter = server_context[:rate_limiter]
+
+ # Check rate limit
+ rate_limiter.check_rate_limit!('search_modules')
+
+ # Validate inputs
+ Msf::MCP::Security::InputValidator.validate_search_query!(query)
+ Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset)
+
+ # Call Metasploit API
+ raw_modules = msf_client.search_modules(query)
+
+ # Transform response
+ transformed = Metasploit::ResponseTransformer.transform_modules(raw_modules)
+
+ # Apply pagination
+ #
+ # Note that to get the total number of entries, we gather the entire data set and apply pagination here
+ # instead of sending the limit and offset to the API call to be processed by MSF.
+ # This is needed to provide accurate total_items count in the metadata.
+ total_items = transformed.size
+ paginated_data = transformed[offset, limit] || []
+
+ # Build metadata
+ metadata = {
+ query: query,
+ query_time: (Time.now - start_time).round(3),
+ total_items: total_items,
+ returned_items: paginated_data.size,
+ limit: limit,
+ offset: offset
+ }
+
+ # Return MCP response
+ ::MCP::Tool::Response.new(
+ [
+ {
+ type: 'text',
+ text: JSON.generate(
+ metadata: metadata,
+ data: paginated_data
+ )
+ }
+ ],
+ structured_content: {
+ metadata: metadata,
+ data: paginated_data
+ }
+ )
+ rescue Msf::MCP::Security::RateLimitExceededError => e
+ tool_error_response("Rate limit exceeded: #{e.message}")
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ tool_error_response("Authentication failed: #{e.message}")
+ rescue Msf::MCP::Metasploit::APIError => e
+ tool_error_response("Metasploit API error: #{e.message}")
+ rescue Msf::MCP::Security::ValidationError => e
+ tool_error_response(e.message)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/service_info.rb b/lib/msf/core/mcp/tools/service_info.rb
new file mode 100644
index 0000000000000..053e017de4e58
--- /dev/null
+++ b/lib/msf/core/mcp/tools/service_info.rb
@@ -0,0 +1,197 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # MCP Tool: Query Metasploit Database Services
+ #
+ # Retrieves service information from the Metasploit database including
+ # ports, protocols, and service banners.
+ #
+ class ServiceInfo < ::MCP::Tool
+ tool_name 'msf_service_info'
+ description 'Query Metasploit database for discovered services. '\
+ 'Returns service information including ports, protocols, and banners.'
+
+ input_schema(
+
+ properties: {
+ workspace: {
+ type: 'string',
+ description: 'Workspace name (default: "default")',
+ default: 'default'
+ },
+ names: {
+ type: 'string',
+ description: 'Comma-separated service names to filter (e.g., "http,https,ssh")'
+ },
+ host: {
+ type: 'string',
+ description: 'Host IP address (e.g., "192.168.1.100")'
+ },
+ ports: {
+ type: 'string',
+ description: 'Port number or range to filter (e.g., "80" or "80-443")'
+ },
+ protocol: {
+ type: 'string',
+ description: 'Protocol to filter (tcp or udp)',
+ enum: ['tcp', 'udp']
+ },
+ only_up: {
+ type: 'boolean',
+ description: 'Filter to only return services on hosts that are up',
+ default: false
+ },
+ limit: {
+ type: 'integer',
+ description: 'Maximum number of results',
+ minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN,
+ maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX,
+ default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT
+ },
+ offset: {
+ type: 'integer',
+ description: 'Number of results to skip',
+ minimum: 0,
+ default: 0
+ }
+ },
+ required: [:workspace]
+ )
+
+ output_schema(
+ properties: {
+ metadata: {
+ properties: {
+ workspace: { type: 'string' },
+ query_time: { type: 'number' },
+ total_items: { type: 'integer' },
+ returned_items: { type: 'integer' },
+ limit: { type: 'integer' },
+ offset: { type: 'integer' }
+ }
+ },
+ data: {
+ type: 'array',
+ items: {
+ properties: {
+ host_address: { type: 'string' },
+ created_at: { type: 'string' },
+ updated_at: { type: 'string' },
+ port: { type: 'integer' },
+ protocol: { type: 'string' },
+ state: { type: 'string' },
+ name: { type: 'string' },
+ info: { type: 'string' }
+ }
+ }
+ }
+ },
+ required: [:metadata, :data]
+ )
+
+ annotations(
+ read_only_hint: true,
+ idempotent_hint: true,
+ destructive_hint: false
+ )
+
+ meta({ source: 'metasploit_database' })
+
+ class << self
+ include ToolHelper
+
+ ##
+ # Execute service query
+ #
+ # @param workspace [String] Workspace name (default: 'default')
+ # @param names [String, nil] Comma-separated service names to filter
+ # @param ports [String, nil] Port number or range to filter
+ # @param host [String, nil] Host IP address
+ # @param protocol [String, nil] Protocol to filter (tcp or udp)
+ # @param only_up [Boolean] Filter to only return services on hosts that are up
+ # @param limit [Integer] Maximum results (default: 100)
+ # @param offset [Integer] Results offset (default: 0)
+ # @param server_context [Hash] Server context with msf_client, rate_limiter, config
+ # @return [MCP::Tool::Response] Structured response with service information
+ #
+ def call(workspace: 'default', names: nil, ports: nil, host: nil, protocol: nil, only_up: false, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:)
+ start_time = Time.now
+
+ # Extract dependencies from server context
+ msf_client = server_context[:msf_client]
+ rate_limiter = server_context[:rate_limiter]
+
+ # Check rate limit
+ rate_limiter.check_rate_limit!('service_info')
+
+ # Validate inputs
+ Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset)
+ Msf::MCP::Security::InputValidator.validate_only_up!(only_up)
+ Msf::MCP::Security::InputValidator.validate_protocol!(protocol) if protocol
+ Msf::MCP::Security::InputValidator.validate_ip_address!(host) if host
+ Msf::MCP::Security::InputValidator.validate_port_range!(ports) if ports
+
+ # Call Metasploit API
+ # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided.
+ # The default value is sent anyway for clarity.
+ options = { workspace: workspace }
+ options[:only_up] = only_up if only_up
+ options[:proto] = protocol if protocol
+ # The API is misleading, it only supports a single address filter, not multiple.
+ options[:addresses] = host if host
+ options[:ports] = ports if ports
+ options[:names] = names if names
+ raw_services = msf_client.db_services(options)
+
+ # Transform response
+ transformed = Metasploit::ResponseTransformer.transform_services(raw_services)
+
+ # Apply pagination
+ #
+ # Note that to get the total number of entries, we gather the entire data set and apply pagination here
+ # instead of sending the limit and offset to the API call to be processed by MSF.
+ # This is needed to provide accurate total_items count in the metadata.
+ total_items = transformed.size
+ paginated_data = transformed[offset, limit] || []
+
+ # Build metadata
+ metadata = {
+ workspace: workspace,
+ query_time: (Time.now - start_time).round(3),
+ total_items: total_items,
+ returned_items: paginated_data.size,
+ limit: limit,
+ offset: offset
+ }
+
+ # Return MCP response
+ ::MCP::Tool::Response.new(
+ [
+ {
+ type: 'text',
+ text: JSON.generate(
+ metadata: metadata,
+ data: paginated_data
+ )
+ }
+ ],
+ structured_content: {
+ metadata: metadata,
+ data: paginated_data
+ }
+ )
+ rescue Msf::MCP::Security::RateLimitExceededError => e
+ tool_error_response("Rate limit exceeded: #{e.message}")
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ tool_error_response("Authentication failed: #{e.message}")
+ rescue Msf::MCP::Metasploit::APIError => e
+ tool_error_response("Metasploit API error: #{e.message}")
+ rescue Msf::MCP::Security::ValidationError => e
+ tool_error_response(e.message)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/tool_helper.rb b/lib/msf/core/mcp/tools/tool_helper.rb
new file mode 100644
index 0000000000000..56cc84b788120
--- /dev/null
+++ b/lib/msf/core/mcp/tools/tool_helper.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # Shared helper methods for MCP tools.
+ #
+ # Provides a standard way to build error responses that comply with the
+ # MCP protocol, returning a normal result with `isError: true` instead
+ # of raising exceptions that the MCP server would wrap as internal errors.
+ #
+ module ToolHelper
+ ##
+ # Build a standard MCP error response.
+ #
+ # @param message [String] Human-readable error message
+ # @return [::MCP::Tool::Response] Response with isError flag set
+ #
+ def tool_error_response(message)
+ ::MCP::Tool::Response.new(
+ [{ type: 'text', text: message }],
+ error: true
+ )
+ end
+ end
+ end
+end
diff --git a/lib/msf/core/mcp/tools/vulnerability_info.rb b/lib/msf/core/mcp/tools/vulnerability_info.rb
new file mode 100644
index 0000000000000..8a548139366fa
--- /dev/null
+++ b/lib/msf/core/mcp/tools/vulnerability_info.rb
@@ -0,0 +1,189 @@
+# frozen_string_literal: true
+
+module Msf::MCP
+ module Tools
+ ##
+ # MCP Tool: Query Metasploit Database Vulnerabilities
+ #
+ # Retrieves vulnerability information from the Metasploit database including
+ # CVE IDs, affected hosts, and vulnerability details.
+ #
+ class VulnerabilityInfo < ::MCP::Tool
+ tool_name 'msf_vulnerability_info'
+ description 'Query Metasploit database for discovered vulnerabilities. '\
+ 'Returns vulnerability information including CVE IDs and affected hosts.'
+
+ input_schema(
+
+ properties: {
+ workspace: {
+ type: 'string',
+ description: 'Workspace name (default: "default")',
+ default: 'default'
+ },
+ names: {
+ type: 'array',
+ items: {
+ type: 'string',
+ description: 'Exploit that reported the vulnerability. It needs to be the exact module name, case sensitive, not the path (e.g. "SSH User Code Execution" or "WebEx Local Service Permissions Exploit").'
+ }
+ },
+ host: {
+ type: 'string',
+ description: 'Host IP address to filter (e.g., "192.168.1.100")'
+ },
+ ports: {
+ type: 'string',
+ description: 'Port number or range to filter (e.g., "80" or "80-443")'
+ },
+ protocol: {
+ type: 'string',
+ description: 'Protocol to filter (tcp or udp)',
+ enum: ['tcp', 'udp']
+ },
+ limit: {
+ type: 'integer',
+ description: 'Maximum number of results',
+ minimum: Msf::MCP::Security::InputValidator::LIMIT_MIN,
+ maximum: Msf::MCP::Security::InputValidator::LIMIT_MAX,
+ default: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT
+ },
+ offset: {
+ type: 'integer',
+ description: 'Number of results to skip',
+ minimum: 0,
+ default: 0
+ }
+ },
+ required: [:workspace]
+ )
+
+ output_schema(
+ properties: {
+ metadata: {
+ properties: {
+ workspace: { type: 'string' },
+ query_time: { type: 'number' },
+ total_items: { type: 'integer' },
+ returned_items: { type: 'integer' },
+ limit: { type: 'integer' },
+ offset: { type: 'integer' }
+ }
+ },
+ data: {
+ type: 'array',
+ items: {
+ properties: {
+ host: { type: 'string' },
+ port: { type: 'integer' },
+ protocol: { type: 'string' },
+ name: { type: 'string' },
+ references: { type: 'array', items: { type: 'string' } },
+ created_at: { type: 'string' }
+ }
+ }
+ }
+ },
+ required: [:metadata, :data]
+ )
+
+ annotations(
+ read_only_hint: true,
+ idempotent_hint: true,
+ destructive_hint: false
+ )
+
+ meta({ source: 'metasploit_database' })
+
+ class << self
+ include ToolHelper
+
+ ##
+ # Execute vulnerability query
+ #
+ # @param workspace [String] Workspace name (default: 'default')
+ # @param host [String, nil] Host IP address to filter
+ # @param names [Array, nil] Exploit names to filter
+ # @param ports [String, nil] Port or port range to filter
+ # @param protocol [String, nil] Protocol to filter (tcp or udp)
+ # @param limit [Integer] Maximum results (default: 100)
+ # @param offset [Integer] Results offset (default: 0)
+ # @param server_context [Hash] Server context with msf_client, rate_limiter, config
+ # @return [MCP::Tool::Response] Structured response with vulnerability information
+ #
+ def call(workspace: 'default', host: nil, names: nil, ports: nil, protocol: nil, limit: Msf::MCP::Security::InputValidator::LIMIT_DEFAULT, offset: 0, server_context:)
+ start_time = Time.now
+
+ # Extract dependencies from server context
+ msf_client = server_context[:msf_client]
+ rate_limiter = server_context[:rate_limiter]
+
+ # Check rate limit
+ rate_limiter.check_rate_limit!('vulnerability_info')
+
+ # Validate inputs
+ Msf::MCP::Security::InputValidator.validate_pagination!(limit, offset)
+ Msf::MCP::Security::InputValidator.validate_protocol!(protocol) if protocol
+ Msf::MCP::Security::InputValidator.validate_ip_address!(host) if host
+ Msf::MCP::Security::InputValidator.validate_port_range!(ports) if ports
+
+ # Call Metasploit API
+ # Note that `workspace` is optional in the MSF API, the default workspace is used if not provided.
+ # The default value is sent anyway for clarity.
+ options = { workspace: workspace }
+ options[:address] = host if host
+ options[:names] = names.join(',') if names && names.is_a?(Array) && names.any?
+ options[:ports] = ports if ports
+ options[:proto] = protocol if protocol
+ raw_vulns = msf_client.db_vulns(options)
+
+ # Transform response
+ transformed = Metasploit::ResponseTransformer.transform_vulns(raw_vulns)
+
+ # Apply pagination
+ #
+ # Note that to get the total number of entries, we gather the entire data set and apply pagination here
+ # instead of sending the limit and offset to the API call to be processed by MSF.
+ # This is needed to provide accurate total_items count in the metadata.
+ total_items = transformed.size
+ paginated_data = transformed[offset, limit] || []
+
+ # Build metadata
+ metadata = {
+ workspace: workspace,
+ query_time: (Time.now - start_time).round(3),
+ total_items: total_items,
+ returned_items: paginated_data.size,
+ limit: limit,
+ offset: offset
+ }
+
+ # Return MCP response
+ ::MCP::Tool::Response.new(
+ [
+ {
+ type: 'text',
+ text: JSON.generate(
+ metadata: metadata,
+ data: paginated_data
+ )
+ }
+ ],
+ structured_content: {
+ metadata: metadata,
+ data: paginated_data
+ }
+ )
+ rescue Msf::MCP::Security::RateLimitExceededError => e
+ tool_error_response("Rate limit exceeded: #{e.message}")
+ rescue Msf::MCP::Metasploit::AuthenticationError => e
+ tool_error_response("Authentication failed: #{e.message}")
+ rescue Msf::MCP::Metasploit::APIError => e
+ tool_error_response("Metasploit API error: #{e.message}")
+ rescue Msf::MCP::Security::ValidationError => e
+ tool_error_response(e.message)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/msf_autoload.rb b/lib/msf_autoload.rb
index d69ac6cf93ba3..3ac7924eb8833 100644
--- a/lib/msf_autoload.rb
+++ b/lib/msf_autoload.rb
@@ -65,7 +65,9 @@ def ignore_list
"#{__dir__}/rex/post.rb",
"#{__dir__}/rex/proto/ssh/hrr_rb_ssh.rb",
"#{__dir__}/rex/proto/ssh/connection.rb",
- "#{__dir__}/rex/proto/kerberos/pac/krb5_pac.rb"
+ "#{__dir__}/rex/proto/kerberos/pac/krb5_pac.rb",
+ "#{__dir__}/msf/core/mcp.rb",
+ "#{__dir__}/msf/core/mcp/"
]
end
diff --git a/lib/rex/logging.rb b/lib/rex/logging.rb
index eb2fb80bacb06..59a940be8b918 100644
--- a/lib/rex/logging.rb
+++ b/lib/rex/logging.rb
@@ -1,5 +1,6 @@
# -*- coding: binary -*-
-module Rex::Logging
+module Rex
+module Logging
#
@@ -65,3 +66,4 @@ module Rex::Logging
require 'rex/logging/log_dispatcher'
end
+end
diff --git a/metasploit-framework.gemspec b/metasploit-framework.gemspec
index cdc8ab16286b8..219b877157f12 100644
--- a/metasploit-framework.gemspec
+++ b/metasploit-framework.gemspec
@@ -271,6 +271,8 @@ Gem::Specification.new do |spec|
# Needed for caching validation
spec.add_runtime_dependency 'parallel'
+ spec.add_runtime_dependency 'mcp', '0.13.0'
+
# Standard libraries: https://www.ruby-lang.org/en/news/2023/12/25/ruby-3-3-0-released/
%w[
abbrev
diff --git a/msfmcpd b/msfmcpd
new file mode 100755
index 0000000000000..46acc0a0e522f
--- /dev/null
+++ b/msfmcpd
@@ -0,0 +1,53 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+# MSF MCP Server - Model Context Protocol server for Metasploit Framework
+#
+# This executable provides a MCP server that exposes Metasploit
+# functionality to AI agents and LLM applications.
+#
+# Usage:
+# msfmcpd [options]
+#
+# Options:
+# --config PATH Path to configuration file
+# --enable-logging Enable file logging with sanitization
+# --log-file PATH Log file path (overrides config file)
+# --user USER MSF API username (for MessagePack auth)
+# --password PASS MSF API password (for MessagePack auth)
+# --no-auto-start-rpc Disable automatic RPC server startup
+# -h, --help Show this help message
+# -v, --version Show version information
+#
+# Settings can be overridden by environment variables:
+# MSF_API_TYPE Metasploit RPC API connection type ('messagepack' or 'json-rpc')
+# MSF_API_HOST Metasploit RPC API host
+# MSF_API_PORT Metasploit RPC API port
+# MSF_API_SSL Use SSL for Metasploit RPC API
+# MSF_API_ENDPOINT Metasploit RPC API endpoint
+# MSF_API_USER Metaspoit RPC API username (for MessagePack auth)
+# MSF_API_PASSWORD Metaspoit RPC API password (for MessagePack auth)
+# MSF_API_TOKEN Metaspoit RPC API token (for JSON-RPC auth)
+# MSF_AUTO_START_RPC Auto-start Metasploit RPC server ('true' or 'false')
+# MSF_MCP_TRANSPORT MCP server transport type ('stdio' or 'http')
+# MSF_MCP_HOST MCP server host
+# MSF_MCP_PORT MCP server port
+#
+# Examples:
+# # Start with default configuration (default: MessagePack connection type, so credentials must be provided)
+# msfmcpd --user msf_user --password msf_pass
+#
+# # Start with custom configuration and logging
+# msfmcpd --config ./config/mcp_config.yaml --enable-logging
+#
+# # Use environment variables to override the configuration
+# MSF_API_HOST=192.168.33.44 msfmcpd --config ./config/mcp_config.yaml
+
+require 'bundler/setup'
+
+lib_path = File.join(File.dirname(__dir__), 'lib')
+$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
+
+require 'msf/core/mcp'
+
+Msf::MCP::Application.new(ARGV).run
diff --git a/msfrpcd b/msfrpcd
index 4e83804d27797..52b5c8eddc968 100755
--- a/msfrpcd
+++ b/msfrpcd
@@ -203,6 +203,11 @@ if $PROGRAM_NAME == __FILE__
end
}
+ # Allow credentials via environment variables (used by msfmcpd auto-start
+ # to avoid passing secrets on the command line).
+ opts['User'] ||= ENV['MSF_RPC_USER'] if ENV['MSF_RPC_USER']
+ opts['Pass'] ||= ENV['MSF_RPC_PASS'] if ENV['MSF_RPC_PASS']
+
$0 = "msfrpcd"
begin
diff --git a/spec/file_fixtures/config_files/msfmcpd/valid_jsonrpc.yaml b/spec/file_fixtures/config_files/msfmcpd/valid_jsonrpc.yaml
new file mode 100644
index 0000000000000..314e79bffac13
--- /dev/null
+++ b/spec/file_fixtures/config_files/msfmcpd/valid_jsonrpc.yaml
@@ -0,0 +1,24 @@
+# Valid JSON-RPC configuration for testing
+msf_api:
+ type: json-rpc
+ host: localhost
+ port: 8081
+ endpoint: /api/v1/json-rpc
+ token: test_bearer_token_12345
+
+# MCP server configuration
+mcp:
+ transport: stdio
+ host: localhost
+ port: 3000
+
+# Rate limiting
+rate_limit:
+ enabled: true
+ requests_per_minute: 60
+ burst_size: 10
+
+# Logging
+logging:
+ enabled: false
+ level: INFO
diff --git a/spec/file_fixtures/config_files/msfmcpd/valid_messagepack.yaml b/spec/file_fixtures/config_files/msfmcpd/valid_messagepack.yaml
new file mode 100644
index 0000000000000..56cba28e9442c
--- /dev/null
+++ b/spec/file_fixtures/config_files/msfmcpd/valid_messagepack.yaml
@@ -0,0 +1,25 @@
+# Valid MessagePack configuration for testing
+msf_api:
+ type: messagepack
+ host: localhost
+ port: 55553
+ endpoint: /api/
+ user: test_user
+ password: test_password
+
+# MCP server configuration
+mcp:
+ transport: stdio
+ host: localhost
+ port: 3000
+
+# Rate limiting
+rate_limit:
+ enabled: true
+ requests_per_minute: 60
+ burst_size: 10
+
+# Logging
+logging:
+ enabled: false
+ level: INFO
diff --git a/spec/integration/msfmcpd/config_loading_spec.rb b/spec/integration/msfmcpd/config_loading_spec.rb
new file mode 100644
index 0000000000000..3be855a9abbc1
--- /dev/null
+++ b/spec/integration/msfmcpd/config_loading_spec.rb
@@ -0,0 +1,323 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+require 'tempfile'
+
+RSpec.describe 'Configuration Loading and Validation Integration' do
+ let(:file_fixtures_path) { File.join(Msf::Config.install_root, 'spec', 'file_fixtures') }
+ let(:valid_messagepack_path) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_messagepack.yaml') }
+ let(:valid_jsonrpc_path) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_jsonrpc.yaml') }
+
+ before do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after do
+ WebMock.allow_net_connect!
+ end
+
+ describe 'Application Initialization with Configuration' do
+ let(:output) { StringIO.new }
+ let(:argv) { ['--config', valid_messagepack_path] }
+
+ it 'successfully initializes all components with valid MessagePack config' do
+ app = Msf::MCP::Application.new(argv, output: output)
+
+ # Stub Metasploit authentication
+ stub_request(:post, 'https://localhost:55553/api/')
+ .to_return(
+ status: 200,
+ body: MessagePack.pack({
+ 'result' => 'success',
+ 'token' => 'fake_token_123'
+ })
+ )
+
+ # Execute initialization steps (before start)
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+ app.send(:validate_configuration)
+ app.send(:initialize_rate_limiter)
+ app.send(:initialize_metasploit_client)
+
+ # Verify components are initialized with config values
+ expect(app.config[:msf_api][:type]).to eq('messagepack')
+ expect(app.config[:msf_api][:host]).to eq('localhost')
+ expect(app.config[:msf_api][:port]).to eq(55553)
+
+ # Verify rate limiter is configured from config
+ expect(app.rate_limiter).to be_a(Msf::MCP::Security::RateLimiter)
+ expect(app.rate_limiter.instance_variable_get(:@requests_per_minute)).to eq(60)
+ expect(app.rate_limiter.instance_variable_get(:@burst_size)).to eq(10)
+
+ # Verify Metasploit client is created with config values
+ expect(app.msf_client).to be_a(Msf::MCP::Metasploit::Client)
+ end
+
+ it 'successfully initializes all components with valid JSON-RPC config' do
+ argv = ['--config', valid_jsonrpc_path]
+ app = Msf::MCP::Application.new(argv, output: output)
+
+ # Execute initialization steps
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+ app.send(:validate_configuration)
+ app.send(:initialize_rate_limiter)
+ app.send(:initialize_metasploit_client)
+
+ # Verify config is loaded correctly
+ expect(app.config[:msf_api][:type]).to eq('json-rpc')
+ expect(app.config[:msf_api][:port]).to eq(8081)
+ expect(app.config[:msf_api][:token]).to eq('test_bearer_token_12345')
+
+ # Verify components are initialized
+ expect(app.rate_limiter).to be_a(Msf::MCP::Security::RateLimiter)
+ expect(app.msf_client).to be_a(Msf::MCP::Metasploit::Client)
+ end
+
+ it 'applies defaults and starts successfully with minimal config' do
+ minimal_config = {
+ msf_api: {
+ type: 'messagepack',
+ user: 'msf',
+ password: 'pass'
+ }
+ }
+
+ config_file = Tempfile.new(['minimal_config', '.yaml'])
+ # Dirty hack to make sure the config hash keys are strings and not symbols.
+ config_file.write(YAML.dump(JSON.parse(minimal_config.to_json)))
+ config_file.flush
+
+ app = Msf::MCP::Application.new(['--config', config_file.path], output: output)
+
+ # Stub authentication
+ stub_request(:post, 'https://localhost:55553/api/')
+ .to_return(
+ status: 200,
+ body: MessagePack.pack({
+ 'result' => 'success',
+ 'token' => 'token'
+ })
+ )
+
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+ app.send(:validate_configuration)
+
+ # Verify defaults were applied
+ expect(app.config[:msf_api][:host]).to eq('localhost')
+ expect(app.config[:msf_api][:port]).to eq(55553)
+ expect(app.config[:rate_limit][:requests_per_minute]).to eq(60)
+ expect(app.config[:logging][:level]).to eq('INFO')
+
+ # Verify can proceed with initialization
+ expect {
+ app.send(:initialize_rate_limiter)
+ app.send(:initialize_metasploit_client)
+ }.not_to raise_error
+
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'fails gracefully when config has invalid port' do
+ invalid_config = YAML.safe_load_file(valid_messagepack_path)
+ invalid_config['msf_api']['port'] = 999999
+
+ config_file = Tempfile.new(['invalid_port', '.yaml'])
+ config_file.write(YAML.dump(invalid_config))
+ config_file.flush
+
+ app = Msf::MCP::Application.new(['--config', config_file.path], output: output)
+
+ expect {
+ app.run
+ }.to raise_error(SystemExit)
+
+ expect(output.string).to include('Configuration validation failed')
+ expect(output.string).to include('port must be between')
+
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'fails gracefully when config is missing required authentication on remote host' do
+ invalid_config = {
+ msf_api: {
+ type: 'messagepack',
+ host: '192.168.1.100',
+ port: 55553,
+ auto_start_rpc: false
+ }
+ }
+
+ config_file = Tempfile.new(['missing_auth', '.yaml'])
+ # Dirty hack to make sure the config hash keys are strings and not symbols.
+ config_file.write(YAML.dump(JSON.parse(invalid_config.to_json)))
+ config_file.flush
+
+ app = Msf::MCP::Application.new(['--config', config_file.path], output: output)
+
+ expect {
+ app.run
+ }.to raise_error(SystemExit)
+
+ expect(output.string).to include('Configuration validation failed')
+ expect(output.string).to match(/user|password/)
+
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'prevents application startup with invalid API type' do
+ invalid_config = YAML.safe_load_file(valid_messagepack_path)
+ invalid_config['msf_api']['type'] = 'soap'
+
+ config_file = Tempfile.new(['invalid_type', '.yaml'])
+ config_file.write(YAML.dump(invalid_config))
+ config_file.flush
+
+ app = Msf::MCP::Application.new(['--config', config_file.path], output: output)
+
+ expect {
+ app.run
+ }.to raise_error(SystemExit)
+
+ expect(output.string).to include('Configuration validation failed')
+ expect(output.string).to include('msf_api.type')
+
+ config_file.close
+ config_file.unlink
+ end
+ end
+
+ describe 'Environment Variable Override Integration' do
+ let(:output) { StringIO.new }
+
+ after do
+ # Clean up ENV
+ %w[MSF_API_HOST MSF_API_PORT MSF_API_TYPE MSF_API_USER MSF_API_PASSWORD MSF_API_TOKEN].each { |key| ENV.delete(key) }
+ end
+
+ it 'ENV override changes which Metasploit host client connects to' do
+ ENV['MSF_API_HOST'] = '192.168.1.100'
+
+ app = Msf::MCP::Application.new(['--config', valid_messagepack_path], output: output)
+
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+ app.send(:validate_configuration)
+ app.send(:initialize_metasploit_client)
+
+ # Verify client was created with ENV-overridden host
+ expect(app.config[:msf_api][:host]).to eq('192.168.1.100')
+
+ # Verify that making an API call would use the overridden host
+ stub_request(:post, 'https://192.168.1.100:55553/api/')
+ .to_return(
+ status: 200,
+ body: MessagePack.pack({
+ 'result' => 'success',
+ 'token' => 'token_123'
+ })
+ )
+
+ expect {
+ app.msf_client.authenticate('test_user', 'test_password')
+ }.not_to raise_error
+
+ # Verify the request was made to the correct host
+ expect(WebMock).to have_requested(:post, 'https://192.168.1.100:55553/api/').once
+ end
+
+ it 'ENV override changes authentication credentials used' do
+ ENV['MSF_API_USER'] = 'env_override_user'
+ ENV['MSF_API_PASSWORD'] = 'env_override_pass'
+
+ app = Msf::MCP::Application.new(['--config', valid_messagepack_path], output: output)
+
+ # Stub authentication - accept any body since MessagePack matching is problematic
+ stub_request(:post, 'https://localhost:55553/api/')
+ .to_return(
+ status: 200,
+ body: MessagePack.pack({
+ 'result' => 'success',
+ 'token' => 'env_token'
+ })
+ )
+
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+ app.send(:validate_configuration)
+ app.send(:initialize_metasploit_client)
+
+ # Verify ENV vars override config file
+ expect(app.config[:msf_api][:user]).to eq('env_override_user')
+ expect(app.config[:msf_api][:password]).to eq('env_override_pass')
+
+ # Verify authentication uses ENV credentials
+ app.send(:authenticate_metasploit)
+
+ expect(WebMock).to have_requested(:post, 'https://localhost:55553/api/').once
+ end
+
+ it 'ENV override changes API type from MessagePack to JSON-RPC' do
+ ENV['MSF_API_TYPE'] = 'json-rpc'
+ ENV['MSF_API_PORT'] = '8081'
+ ENV['MSF_API_TOKEN'] = 'env_token_123' # JSON-RPC requires token
+
+ app = Msf::MCP::Application.new(['--config', valid_messagepack_path], output: output)
+
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+ app.send(:validate_configuration)
+ app.send(:initialize_metasploit_client)
+
+ # Verify type was overridden
+ expect(app.config[:msf_api][:type]).to eq('json-rpc')
+ expect(app.config[:msf_api][:port]).to eq(8081)
+ expect(app.config[:msf_api][:token]).to eq('env_token_123')
+
+ # Verify client is JSON-RPC client (not MessagePack)
+ underlying_client = app.msf_client.instance_variable_get(:@client)
+ expect(underlying_client).to be_a(Msf::MCP::Metasploit::JsonRpcClient)
+ end
+ end
+
+ describe 'CLI Flag Override Integration' do
+ let(:output) { StringIO.new }
+
+ it 'CLI --user and --password flags override config file authentication' do
+ app = Msf::MCP::Application.new(
+ ['--config', valid_messagepack_path, '--user', 'cli_user', '--password', 'cli_pass'],
+ output: output
+ )
+
+ # Stub authentication - accept any body
+ stub_request(:post, 'https://localhost:55553/api/')
+ .to_return(
+ status: 200,
+ body: MessagePack.pack({
+ 'result' => 'success',
+ 'token' => 'cli_token'
+ })
+ )
+
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+
+ # Verify CLI args override config file
+ expect(app.config[:msf_api][:user]).to eq('cli_user')
+ expect(app.config[:msf_api][:password]).to eq('cli_pass')
+
+ app.send(:validate_configuration)
+ app.send(:initialize_metasploit_client)
+ app.send(:authenticate_metasploit)
+
+ # Verify authentication was called
+ expect(WebMock).to have_requested(:post, 'https://localhost:55553/api/').once
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/error_handling_spec.rb b/spec/integration/msfmcpd/error_handling_spec.rb
new file mode 100644
index 0000000000000..21a7dc95a00ce
--- /dev/null
+++ b/spec/integration/msfmcpd/error_handling_spec.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+
+RSpec.describe 'Error Handling Integration' do
+ # Disable real HTTP connections for integration tests
+ before(:all) do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after(:all) do
+ WebMock.allow_net_connect!
+ end
+
+ let(:host) { 'localhost' }
+ let(:port) { 55553 }
+
+ describe 'MessagePack Error Handling' do
+ let(:endpoint) { '/api/' }
+ let(:api_url) { "https://#{host}:#{port}#{endpoint}" }
+ let(:client) do
+ Msf::MCP::Metasploit::Client.new(
+ api_type: 'messagepack',
+ host: host,
+ port: port,
+ endpoint: endpoint,
+ ssl: true
+ )
+ end
+
+ before do
+ # Mock successful authentication to get the token
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', 'user', 'pass'].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ client.authenticate('user', 'pass')
+ end
+
+ describe 'Network Connection Failures' do
+ it 'converts Errno::ECONNREFUSED to ConnectionError' do
+ # Mock a connection refused error
+ stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'smb'].to_msgpack)
+ .to_raise(Errno::ECONNREFUSED)
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /Cannot connect to Metasploit RPC/)
+ end
+
+ it 'converts SocketError to ConnectionError' do
+ stub_request(:post, api_url)
+ .to_raise(SocketError.new('getaddrinfo: Name or service not known'))
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /Network error/)
+ end
+
+ it 'converts Timeout::Error to ConnectionError' do
+ # Timeout on search
+ stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'smb'].to_msgpack)
+ .to_raise(Timeout::Error.new('execution expired'))
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /Request timeout/)
+ end
+
+ it 'converts EOFError to ConnectionError' do
+ stub_request(:post, api_url)
+ .to_raise(EOFError.new('end of file reached'))
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /Empty response/)
+ end
+ end
+
+ describe 'HTTP Status Code Handling' do
+ it 'converts HTTP 401 to AuthenticationError' do
+ # Reauthenticate with invalid creds
+ stub_request(:post, api_url)
+ .to_return(
+ status: 401,
+ body: { 'error_message' => 'Invalid credentials' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ expect { client.authenticate('invalid', 'invalid') }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, /Invalid credentials/)
+ end
+
+ it 'converts HTTP 500 to APIError' do
+ # Server error on API call
+ stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'smb'].to_msgpack)
+ .to_return(
+ status: 500,
+ body: { 'error_message' => 'Internal server error' }.to_msgpack
+ )
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::APIError, /Internal server error/)
+ end
+
+ it 'converts unexpected HTTP status to ConnectionError' do
+ stub_request(:post, api_url)
+ .to_return(status: 503, body: 'Service Unavailable')
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError, /HTTP 503/)
+ end
+ end
+
+ describe 'Tool Error Handling Integration' do
+ let(:rate_limiter) { Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60) }
+ let(:server_context) do
+ {
+ msf_client: client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ it 'converts Metasploit errors to MCP error responses end-to-end' do
+ # Test that errors propagate correctly through the entire stack:
+ # HTTP error → Metasploit exception → MCP error response (isError: true)
+ stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'smb'].to_msgpack)
+ .to_return(
+ status: 401,
+ body: { 'error_message' => 'Token invalid' }.to_msgpack
+ )
+
+ result = Msf::MCP::Tools::SearchModules.call(query: 'smb', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+ end
+ end
+
+ describe 'JSON-RPC Error Handling' do
+ let(:jsonrpc_url) { "https://#{host}:#{port}/api/v1/json-rpc" }
+ let(:client) do
+ Msf::MCP::Metasploit::Client.new(
+ api_type: 'json-rpc',
+ host: host,
+ port: port,
+ endpoint: '/api/v1/json-rpc',
+ token: 'bearer_token',
+ ssl: true
+ )
+ end
+
+ it 'handles JSON-RPC error responses' do
+ stub_request(:post, jsonrpc_url)
+ .to_return(
+ status: 200,
+ body: {
+ jsonrpc: '2.0',
+ error: { code: -32601, message: 'Method not found' },
+ id: 1
+ }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::APIError, /Method not found/)
+ end
+
+ it 'handles network errors with JSON-RPC client' do
+ stub_request(:post, jsonrpc_url)
+ .to_raise(Errno::ECONNREFUSED)
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::ConnectionError)
+ end
+
+ it 'raises error with invalid bearer token' do
+ stub_request(:post, jsonrpc_url)
+ .to_return(
+ status: 401,
+ body: { 'error' => 'Unauthorized' }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ expect { client.search_modules('smb') }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, /Invalid authentication token/)
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/jsonrpc_auth_flow_spec.rb b/spec/integration/msfmcpd/jsonrpc_auth_flow_spec.rb
new file mode 100644
index 0000000000000..b826735a56c31
--- /dev/null
+++ b/spec/integration/msfmcpd/jsonrpc_auth_flow_spec.rb
@@ -0,0 +1,70 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+
+RSpec.describe 'JSON-RPC Authentication Flow Integration' do
+ # Disable real HTTP connections for integration tests
+ before(:all) do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after(:all) do
+ WebMock.allow_net_connect!
+ end
+
+ let(:host) { 'localhost' }
+ let(:port) { 8081 }
+ let(:endpoint) { '/api/v1/json-rpc' }
+ let(:token) { 'test_bearer_token_12345' }
+ let(:jsonrpc_url) { "https://#{host}:#{port}#{endpoint}" }
+
+ describe 'Bearer Token Authentication' do
+ it 'uses bearer token in HTTP headers' do
+ # Stub HTTP endpoint and verify Authorization header
+ stub = stub_request(:post, jsonrpc_url)
+ .with(
+ headers: {
+ 'Authorization' => "Bearer #{token}",
+ 'Content-Type' => 'application/json'
+ }
+ )
+ .to_return(
+ status: 200,
+ body: { jsonrpc: '2.0', result: { modules: [] }, id: 1 }.to_json,
+ headers: { 'Content-Type' => 'application/json' }
+ )
+
+ client = Msf::MCP::Metasploit::JsonRpcClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint,
+ token: token
+ )
+
+ client.call_api('module.search', ['smb'])
+
+ # Verify the HTTP request was made with correct Authorization header
+ expect(stub).to have_been_requested.once
+ end
+
+ it 'follows stateless request pattern (no session management)' do
+ client = Msf::MCP::Metasploit::JsonRpcClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint,
+ token: token
+ )
+
+ # No session storage should exist (only token)
+ expect(client.instance_variable_defined?(:@session_id)).to eq(false)
+ expect(client.instance_variable_defined?(:@session_token)).to eq(false)
+
+ # Has token stored
+ expect(client.instance_variable_get(:@token)).to eq(token)
+
+ # No session state (only token which is stateless)
+ expect(client.instance_variables.grep(/@session/)).to be_empty
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/logging_pipeline_spec.rb b/spec/integration/msfmcpd/logging_pipeline_spec.rb
new file mode 100644
index 0000000000000..0d7bf1fe3f46e
--- /dev/null
+++ b/spec/integration/msfmcpd/logging_pipeline_spec.rb
@@ -0,0 +1,143 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'stringio'
+require 'tempfile'
+require 'json'
+
+RSpec.describe 'Logging Pipeline Integration' do
+ let(:output) { StringIO.new }
+ let(:log_file) { Tempfile.new(['logging_integration', '.log']).tap(&:close).path }
+ let(:log_src) { Msf::MCP::LOG_SOURCE }
+ let(:lvl_info) { Msf::MCP::LOG_INFO }
+ let(:lvl_warn) { Msf::MCP::LOG_WARN }
+ let(:lvl_error) { Msf::MCP::LOG_ERROR }
+
+ after do
+ deregister_log_source(log_src) if log_source_registered?(log_src)
+ File.delete(log_file) if File.exist?(log_file)
+ end
+
+ describe 'initialize_logger with sanitize enabled' do
+ it 'produces JSON log entries with sensitive data redacted' do
+ app = Msf::MCP::Application.new([], output: output)
+ app.send(:parse_arguments)
+ app.instance_variable_set(:@config, {
+ logging: {
+ enabled: true,
+ level: 'INFO',
+ log_file: log_file,
+ sanitize: true
+ }
+ })
+ app.send(:initialize_logger)
+
+ ilog({ message: 'Connection established', context: { password: 's3cret', host: 'localhost' } }, log_src, lvl_info)
+
+ content = File.read(log_file)
+ expect(content).not_to be_empty
+
+ entry = JSON.parse(content.strip.split("\n").last)
+
+ expect(entry['timestamp']).not_to be_nil
+ expect(entry['severity']).to eq('INFO')
+ expect(entry['message']).to include('Connection established')
+
+ expect(entry['context']['password']).to eq('[REDACTED]')
+ expect(entry['context']['host']).to eq('localhost')
+
+ expect(content).not_to include('s3cret')
+ end
+ end
+
+ describe 'initialize_logger with sanitize disabled' do
+ it 'produces JSON log entries without redaction' do
+ app = Msf::MCP::Application.new([], output: output)
+ app.send(:parse_arguments)
+ app.instance_variable_set(:@config, {
+ logging: {
+ enabled: true,
+ level: 'INFO',
+ log_file: log_file,
+ sanitize: false
+ }
+ })
+ app.send(:initialize_logger)
+
+ ilog({ message: 'Connection established', context: { password: 's3cret', host: 'localhost' } }, log_src, lvl_info)
+
+ content = File.read(log_file)
+ entry = JSON.parse(content.strip.split("\n").last)
+
+ expect(entry['severity']).to eq('INFO')
+ expect(entry['message']).to include('Connection established')
+
+ expect(entry['context']['password']).to eq('s3cret')
+ expect(entry['context']['host']).to eq('localhost')
+ end
+ end
+
+ describe 'log level filtering' do
+ it 'filters messages below the configured threshold' do
+ app = Msf::MCP::Application.new([], output: output)
+ app.send(:parse_arguments)
+ app.instance_variable_set(:@config, {
+ logging: {
+ enabled: true,
+ level: 'WARN',
+ log_file: log_file,
+ sanitize: false
+ }
+ })
+ app.send(:initialize_logger)
+
+ # INFO (LEV_2) should be filtered at WARN (LEV_1) threshold
+ ilog({ message: 'This should be filtered' }, log_src, lvl_info)
+ # WARN (LEV_1) should pass
+ wlog({ message: 'This should appear' }, log_src, lvl_warn)
+ # ERROR (LEV_0) should pass
+ elog({ message: 'This error should appear' }, log_src, lvl_error)
+
+ content = File.read(log_file)
+ lines = content.strip.split("\n").reject(&:empty?)
+
+ expect(lines.length).to eq(2)
+
+ entries = lines.map { |l| JSON.parse(l) }
+ expect(entries.map { |e| e['severity'] }).to contain_exactly('WARN', 'ERROR')
+ expect(content).not_to include('This should be filtered')
+ end
+ end
+
+ describe 'exception logging through the pipeline' do
+ it 'formats exceptions as structured JSON with sanitized messages' do
+ app = Msf::MCP::Application.new([], output: output)
+ app.send(:parse_arguments)
+ app.instance_variable_set(:@config, {
+ logging: {
+ enabled: true,
+ level: 'ERROR',
+ log_file: log_file,
+ sanitize: true
+ }
+ })
+ app.send(:initialize_logger)
+
+ error = StandardError.new('Failed with password=hunter2')
+ error.set_backtrace(['lib/msf/core/mcp/server.rb:42:in `start`'])
+
+ elog({ message: 'Startup failed', exception: error }, log_src, lvl_error)
+
+ content = File.read(log_file)
+ entry = JSON.parse(content.strip.split("\n").last)
+
+ expect(entry['severity']).to eq('ERROR')
+ expect(entry['message']).to include('Startup failed')
+ expect(entry['exception']).to be_a(Hash)
+ expect(entry['exception']['class']).to eq('StandardError')
+
+ expect(entry['exception']['message']).to include('[REDACTED]')
+ expect(entry['exception']['message']).not_to include('hunter2')
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/messagepack_auth_flow_spec.rb b/spec/integration/msfmcpd/messagepack_auth_flow_spec.rb
new file mode 100644
index 0000000000000..7d4e82376f2cb
--- /dev/null
+++ b/spec/integration/msfmcpd/messagepack_auth_flow_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+
+RSpec.describe 'MessagePack Authentication Flow Integration' do
+ # Disable real HTTP connections for integration tests
+ before(:all) do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after(:all) do
+ WebMock.allow_net_connect!
+ end
+
+ let(:host) { 'localhost' }
+ let(:port) { 55553 }
+ let(:endpoint) { '/api/' }
+ let(:user) { 'test_user' }
+ let(:password) { 'test_password' }
+ let(:api_url) { "https://#{host}:#{port}#{endpoint}" }
+
+ describe 'Successful Authentication' do
+ it 'authenticates with username and password' do
+ # Stub authentication endpoint
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token_12345' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+
+ token = client.authenticate(user, password)
+ expect(token).to eq('test_token_12345')
+ end
+ end
+
+ describe 'Token Reuse' do
+ it 'stores token for subsequent API calls' do
+ # Stub authentication endpoint
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token_12345' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub subsequent API call with token
+ stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token_12345', 'smb'].to_msgpack)
+ .to_return(
+ status: 200,
+ body: [].to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+
+ stored_token = client.authenticate(user, password)
+
+ # Subsequent request should use the stored token
+ client.call_api('module.search', ['smb'])
+
+ # Token should still be the same
+ expect(client.instance_variable_get(:@token)).to eq(stored_token)
+ expect(stored_token).to eq('test_token_12345')
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/messagepack_reauth_flow_spec.rb b/spec/integration/msfmcpd/messagepack_reauth_flow_spec.rb
new file mode 100644
index 0000000000000..75d9b52abbb34
--- /dev/null
+++ b/spec/integration/msfmcpd/messagepack_reauth_flow_spec.rb
@@ -0,0 +1,157 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+
+RSpec.describe 'MessagePack Re-Authentication Flow Integration' do
+ before(:all) do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after(:all) do
+ WebMock.allow_net_connect!
+ end
+
+ let(:host) { 'localhost' }
+ let(:port) { 55553 }
+ let(:endpoint) { '/api/' }
+ let(:api_url) { "https://#{host}:#{port}#{endpoint}" }
+ let(:user) { 'test_user' }
+ let(:password) { 'test_password' }
+
+ describe 'Automatic Re-Authentication on Token Expiry' do
+ it 're-authenticates and retries when API call returns 401' do
+ call_count = 0
+
+ # Stub all POST requests and dispatch based on body content
+ stub_request(:post, api_url).to_return do |request|
+ body = MessagePack.unpack(request.body)
+ call_count += 1
+
+ case call_count
+ when 1
+ # Initial authentication succeeds
+ expect(body[0]).to eq('auth.login')
+ {
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'initial_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ }
+ when 2
+ # First API call returns 401 (token expired)
+ expect(body[0]).to eq('module.search')
+ expect(body[1]).to eq('initial_token')
+ {
+ status: 401,
+ body: { 'error_message' => 'Token expired' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ }
+ when 3
+ # Re-authentication succeeds with new token
+ expect(body[0]).to eq('auth.login')
+ {
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'refreshed_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ }
+ when 4
+ # Retry with new token succeeds
+ expect(body[0]).to eq('module.search')
+ expect(body[1]).to eq('refreshed_token')
+ {
+ status: 200,
+ body: [{ 'fullname' => 'exploit/test', 'type' => 'exploit', 'name' => 'test' }].to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ }
+ else
+ raise "Unexpected request ##{call_count}: #{body.inspect}"
+ end
+ end
+
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ # This call should trigger: 401 → re-auth → retry → success
+ result = client.search_modules('smb')
+
+ expect(result).to be_an(Array)
+ expect(result.first['fullname']).to eq('exploit/test')
+ expect(client.instance_variable_get(:@token)).to eq('refreshed_token')
+ expect(call_count).to eq(4)
+ end
+
+ it 'gives up after max retries when re-auth succeeds but API keeps failing' do
+ call_count = 0
+
+ stub_request(:post, api_url).to_return do |request|
+ body = MessagePack.unpack(request.body)
+ call_count += 1
+
+ if body[0] == 'auth.login'
+ {
+ status: 200,
+ body: { 'result' => 'success', 'token' => "token_#{call_count}" }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ }
+ else
+ # API calls always return 401
+ {
+ status: 401,
+ body: { 'error_message' => 'Token invalid' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ }
+ end
+ end
+
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ # Should exhaust retries (max_retries=2) and re-raise
+ expect {
+ client.search_modules('smb')
+ }.to raise_error(Msf::MCP::Metasploit::AuthenticationError)
+ end
+
+ it 'propagates re-auth failure through the tool layer as an error response' do
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # All subsequent requests return 401
+ stub_request(:post, api_url)
+ .with { |req| MessagePack.unpack(req.body)[0] != 'auth.login' }
+ .to_return(
+ status: 401,
+ body: { 'error_message' => 'Token invalid' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60)
+ server_context = { msf_client: client, rate_limiter: limiter }
+
+ result = Msf::MCP::Tools::SearchModules.call(query: 'smb', server_context: server_context)
+
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to include('Authentication failed')
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/rate_limiting_spec.rb b/spec/integration/msfmcpd/rate_limiting_spec.rb
new file mode 100644
index 0000000000000..6810dab9c675c
--- /dev/null
+++ b/spec/integration/msfmcpd/rate_limiting_spec.rb
@@ -0,0 +1,151 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+
+RSpec.describe 'Rate Limiting Integration' do
+ # Disable real HTTP connections for integration tests
+ before(:all) do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after(:all) do
+ WebMock.allow_net_connect!
+ end
+
+ let(:host) { 'localhost' }
+ let(:port) { 55553 }
+ let(:endpoint) { '/api/' }
+ let(:api_url) { "https://#{host}:#{port}#{endpoint}" }
+ let(:user) { 'test_user' }
+ let(:password) { 'test_password' }
+
+ describe 'Rate Limiting Across Multiple Tool HTTP Requests' do
+ it 'enforces rate limit across multiple tool calls with HTTP requests' do
+ # Stub authentication endpoint
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub module search endpoint
+ search_stub = stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'smb'].to_msgpack)
+ .to_return(
+ status: 200,
+ body: [{ 'fullname' => 'auxiliary/scanner/smb/smb_version' }].to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Create rate limiter with low limit
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 3, burst_size: 3)
+
+ # Create authenticated client
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ # Create server context
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # First 3 tool calls should succeed (within rate limit)
+ 3.times do
+ expect {
+ Msf::MCP::Tools::SearchModules.call(
+ query: 'smb',
+ limit: 10,
+ server_context: server_context
+ )
+ }.not_to raise_error
+ end
+
+ # Verify exactly 3 HTTP requests were made (plus 1 for auth)
+ expect(search_stub).to have_been_requested.times(3)
+
+ # 4th call should be rate limited before making HTTP request
+ result = Msf::MCP::Tools::SearchModules.call(query: 'smb', limit: 10, server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+
+ # Verify still only 3 search requests (4th was blocked by rate limiter)
+ expect(search_stub).to have_been_requested.times(3)
+ end
+
+ it 'applies global rate limit across different tools with HTTP calls' do
+ # Stub authentication
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub search modules
+ search_stub = stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'smb'].to_msgpack)
+ .to_return(
+ status: 200,
+ body: [].to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub db.hosts
+ hosts_stub = stub_request(:post, api_url)
+ .with(body: ['db.hosts', 'test_token', { workspace: 'default' }].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'hosts' => [] }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 5, burst_size: 5)
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # Make 3 search_modules calls
+ 3.times do
+ Msf::MCP::Tools::SearchModules.call(
+ query: 'smb',
+ limit: 10,
+ server_context: server_context
+ )
+ end
+
+ # Make 2 host_info calls
+ 2.times do
+ Msf::MCP::Tools::HostInfo.call(
+ workspace: 'default',
+ server_context: server_context
+ )
+ end
+
+ # 6th call (different tool) should be rate limited
+ result = Msf::MCP::Tools::SearchModules.call(query: 'http', limit: 10, server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+
+ # Verify HTTP request counts
+ expect(search_stub).to have_been_requested.times(3)
+ expect(hosts_stub).to have_been_requested.times(2)
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/rpc_availability_spec.rb b/spec/integration/msfmcpd/rpc_availability_spec.rb
new file mode 100644
index 0000000000000..2f57768b60a00
--- /dev/null
+++ b/spec/integration/msfmcpd/rpc_availability_spec.rb
@@ -0,0 +1,165 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'stringio'
+require 'tempfile'
+
+RSpec.describe 'RPC Availability Integration' do
+ let(:output) { StringIO.new }
+ let(:file_fixtures_path) { File.join(Msf::Config.install_root, 'spec', 'file_fixtures') }
+ let(:valid_messagepack_path) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_messagepack.yaml') }
+
+ describe 'Application run with RPC already available but no credentials' do
+ it 'exits with RPC startup error when MessagePack credentials are missing' do
+ # Config with no credentials — validator allows this because auto-start
+ # could generate them, but RPC is already running so generation won't happen
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 55553,
+ auto_start_rpc: true
+ }
+ }
+
+ config_file = Tempfile.new(['no_creds', '.yaml'])
+ config_file.write(YAML.dump(JSON.parse(config.to_json)))
+ config_file.flush
+
+ app = Msf::MCP::Application.new(['--config', config_file.path], output: output)
+
+ # Stub RPC as already available
+ allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(true)
+ allow(Signal).to receive(:trap)
+
+ expect { app.run }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+
+ expect(output.string).to include('RPC startup error')
+ expect(output.string).to include('no credentials')
+
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'exits with RPC startup error when JSON-RPC token is missing' do
+ config = {
+ msf_api: {
+ type: 'json-rpc',
+ host: 'localhost',
+ port: 8081
+ }
+ }
+
+ config_file = Tempfile.new(['no_token', '.yaml'])
+ config_file.write(YAML.dump(JSON.parse(config.to_json)))
+ config_file.flush
+
+ app = Msf::MCP::Application.new(['--config', config_file.path], output: output)
+
+ # Stub RPC as already available
+ allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(true)
+ allow(Signal).to receive(:trap)
+
+ expect { app.run }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+
+ # The validator catches missing token before RpcManager runs
+ expect(output.string).to match(/token|Configuration validation failed/i)
+
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'proceeds when RPC is available and credentials are provided' do
+ app = Msf::MCP::Application.new(['--config', valid_messagepack_path], output: output)
+
+ # Stub RPC as already available
+ allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(true)
+
+ # Stub the rest of the startup sequence
+ mock_client = instance_double(Msf::MCP::Metasploit::Client)
+ allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client)
+ allow(mock_client).to receive(:authenticate)
+ mock_server = instance_double(Msf::MCP::Server)
+ allow(Msf::MCP::Server).to receive(:new).and_return(mock_server)
+ allow(mock_server).to receive(:start)
+ allow(Signal).to receive(:trap)
+
+ expect { app.run }.not_to raise_error
+
+ expect(output.string).to include('already running')
+ expect(output.string).to include('Authentication successful')
+ end
+ end
+
+ describe 'Application run with RPC not available' do
+ it 'exits with RPC startup error when auto-start is disabled' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 55553,
+ user: 'msf',
+ password: 'pass',
+ auto_start_rpc: false
+ }
+ }
+
+ config_file = Tempfile.new(['no_autostart', '.yaml'])
+ config_file.write(YAML.dump(JSON.parse(config.to_json)))
+ config_file.flush
+
+ app = Msf::MCP::Application.new(['--config', config_file.path], output: output)
+
+ # Stub RPC as not available
+ allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(false)
+ allow(Signal).to receive(:trap)
+
+ expect { app.run }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+
+ expect(output.string).to include('RPC startup error')
+ expect(output.string).to include('auto-start is disabled')
+
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'exits with RPC startup error on remote host' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: '192.0.2.1',
+ port: 55553,
+ user: 'msf',
+ password: 'pass',
+ auto_start_rpc: true
+ }
+ }
+
+ config_file = Tempfile.new(['remote_host', '.yaml'])
+ config_file.write(YAML.dump(JSON.parse(config.to_json)))
+ config_file.flush
+
+ app = Msf::MCP::Application.new(['--config', config_file.path], output: output)
+
+ # Stub RPC as not available
+ allow_any_instance_of(Msf::MCP::RpcManager).to receive(:rpc_available?).and_return(false)
+ allow(Signal).to receive(:trap)
+
+ expect { app.run }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+
+ expect(output.string).to include('RPC startup error')
+ expect(output.string).to include('192.0.2.1')
+
+ config_file.close
+ config_file.unlink
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/tool_execution_db_spec.rb b/spec/integration/msfmcpd/tool_execution_db_spec.rb
new file mode 100644
index 0000000000000..b9c58a50ab43e
--- /dev/null
+++ b/spec/integration/msfmcpd/tool_execution_db_spec.rb
@@ -0,0 +1,313 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+
+RSpec.describe 'Tool Execution End-to-End - Database Queries' do
+ # Disable real HTTP connections for integration tests
+ before(:all) do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after(:all) do
+ WebMock.allow_net_connect!
+ end
+
+ let(:host) { 'localhost' }
+ let(:port) { 55553 }
+ let(:endpoint) { '/api/' }
+ let(:api_url) { "https://#{host}:#{port}#{endpoint}" }
+ let(:user) { 'test_user' }
+ let(:password) { 'test_password' }
+
+ describe 'Database Query Integration with HTTP' do
+ it 'executes host query through complete HTTP request flow' do
+ # Stub authentication endpoint
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub db.hosts endpoint with realistic response
+ hosts_stub = stub_request(:post, api_url)
+ .with(body: ['db.hosts', 'test_token', { workspace: 'default' }].to_msgpack)
+ .to_return(
+ status: 200,
+ body: {
+ 'hosts' => [
+ {
+ 'address' => '192.168.1.100',
+ 'mac' => '00:11:22:33:44:55',
+ 'name' => 'server01',
+ 'os_name' => 'Linux',
+ 'os_flavor' => 'Ubuntu',
+ 'state' => 'alive',
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ },
+ {
+ 'address' => '192.168.1.101',
+ 'mac' => '00:11:22:33:44:56',
+ 'name' => 'server02',
+ 'os_name' => 'Windows',
+ 'os_flavor' => 'Server 2019',
+ 'state' => 'alive',
+ 'created_at' => 1609459300,
+ 'updated_at' => 1640995300
+ }
+ ]
+ }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Create rate limiter
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10)
+
+ # Create authenticated client
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ # Create server context
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # Execute host query through complete stack
+ result = Msf::MCP::Tools::HostInfo.call(
+ workspace: 'default',
+ server_context: server_context
+ )
+
+ # Verify HTTP request was made
+ expect(hosts_stub).to have_been_requested.once
+
+ # Verify MCP response structure
+ expect(result).to be_a(MCP::Tool::Response)
+ expect(result.content).to be_an(Array)
+ expect(result.content.first[:type]).to eq('text')
+
+ # Verify data transformation occurred correctly
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:address]).to eq('192.168.1.100')
+ expect(data.first[:hostname]).to eq('server01')
+ expect(data.first[:os_name]).to eq('Linux')
+
+ # Verify timestamps transformed to ISO 8601
+ expect(data.first[:created_at]).to eq('2021-01-01T00:00:00Z')
+ expect(data.first[:updated_at]).to eq('2022-01-01T00:00:00Z')
+
+ # Verify metadata
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:workspace]).to eq('default')
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ end
+
+ it 'applies filters correctly through HTTP request' do
+ # Stub authentication
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub db.hosts with filters
+ hosts_stub = stub_request(:post, api_url)
+ .with(body: ['db.hosts', 'test_token', { workspace: 'default', addresses: '192.168.1.0/24', only_up: true }].to_msgpack)
+ .to_return(
+ status: 200,
+ body: {
+ 'hosts' => [
+ {
+ 'address' => '192.168.1.100',
+ 'mac' => '00:11:22:33:44:55',
+ 'name' => 'filtered_host',
+ 'state' => 'alive',
+ 'created_at' => 1609459200
+ }
+ ]
+ }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10)
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # Execute query with filters
+ result = Msf::MCP::Tools::HostInfo.call(
+ workspace: 'default',
+ addresses: '192.168.1.0/24',
+ only_up: true,
+ server_context: server_context
+ )
+
+ # Verify HTTP request with filters was made
+ expect(hosts_stub).to have_been_requested.once
+
+ # Verify filtered results
+ expect(result).to be_a(MCP::Tool::Response)
+ data = result.structured_content[:data]
+ expect(data.length).to eq(1)
+ expect(data.first[:address]).to eq('192.168.1.100')
+ end
+
+ it 'executes service query with multiple filters through HTTP' do
+ # Stub authentication
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub db.services with filters
+ # Note: MessagePack hash key order may vary, so we match any request to db.services
+ services_stub = stub_request(:post, api_url)
+ .with { |request|
+ body = MessagePack.unpack(request.body)
+ body[0] == 'db.services' && body[1] == 'test_token' &&
+ body[2].is_a?(Hash) && body[2]['workspace'] == 'default'
+ }
+ .to_return(
+ status: 200,
+ body: {
+ 'services' => [
+ {
+ 'host' => '192.168.1.100',
+ 'port' => 445,
+ 'proto' => 'tcp',
+ 'name' => 'microsoft-ds',
+ 'state' => 'open',
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ }
+ ]
+ }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10)
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # Execute service query with multiple filters
+ result = Msf::MCP::Tools::ServiceInfo.call(
+ workspace: 'default',
+ host: '192.168.1.100',
+ ports: '445',
+ protocol: 'tcp',
+ server_context: server_context
+ )
+
+ # Verify HTTP request with all filters was made
+ expect(services_stub).to have_been_requested.once
+
+ # Verify results
+ expect(result).to be_a(MCP::Tool::Response)
+ data = result.structured_content[:data]
+ expect(data.length).to eq(1)
+ expect(data.first[:host_address]).to eq('192.168.1.100')
+ expect(data.first[:port]).to eq(445)
+ expect(data.first[:protocol]).to eq('tcp')
+ expect(data.first[:name]).to eq('microsoft-ds')
+ end
+
+ it 'handles pagination correctly across HTTP boundary' do
+ # Stub authentication
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub db.hosts with multiple results
+ hosts_stub = stub_request(:post, api_url)
+ .with(body: ['db.hosts', 'test_token', { workspace: 'default' }].to_msgpack)
+ .to_return(
+ status: 200,
+ body: {
+ 'hosts' => [
+ { 'address' => '192.168.1.1', 'name' => 'host1', 'state' => 'alive', 'created_at' => 1609459200 },
+ { 'address' => '192.168.1.2', 'name' => 'host2', 'state' => 'alive', 'created_at' => 1609459300 },
+ { 'address' => '192.168.1.3', 'name' => 'host3', 'state' => 'alive', 'created_at' => 1609459400 },
+ { 'address' => '192.168.1.4', 'name' => 'host4', 'state' => 'alive', 'created_at' => 1609459500 },
+ { 'address' => '192.168.1.5', 'name' => 'host5', 'state' => 'alive', 'created_at' => 1609459600 }
+ ]
+ }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10)
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # Execute query with pagination (offset=1, limit=2 means items 2 and 3)
+ result = Msf::MCP::Tools::HostInfo.call(
+ workspace: 'default',
+ limit: 2,
+ offset: 1,
+ server_context: server_context
+ )
+
+ # Verify HTTP request was made
+ expect(hosts_stub).to have_been_requested.once
+
+ # Verify pagination applied correctly
+ expect(result).to be_a(MCP::Tool::Response)
+ data = result.structured_content[:data]
+ expect(data.length).to eq(2)
+ expect(data.first[:address]).to eq('192.168.1.2')
+ expect(data.last[:address]).to eq('192.168.1.3')
+
+ # Verify pagination metadata
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(2)
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:total_items]).to eq(5)
+ expect(metadata[:returned_items]).to eq(2)
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/tool_execution_module_info_spec.rb b/spec/integration/msfmcpd/tool_execution_module_info_spec.rb
new file mode 100644
index 0000000000000..28061127b8026
--- /dev/null
+++ b/spec/integration/msfmcpd/tool_execution_module_info_spec.rb
@@ -0,0 +1,128 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+
+RSpec.describe 'Tool Execution End-to-End - Module Info' do
+ before(:all) do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after(:all) do
+ WebMock.allow_net_connect!
+ end
+
+ let(:host) { 'localhost' }
+ let(:port) { 55553 }
+ let(:endpoint) { '/api/' }
+ let(:api_url) { "https://#{host}:#{port}#{endpoint}" }
+ let(:user) { 'test_user' }
+ let(:password) { 'test_password' }
+
+ let(:limiter) { Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10) }
+ let(:client) do
+ c = Msf::MCP::Metasploit::MessagePackClient.new(host: host, port: port, endpoint: endpoint)
+ c.authenticate(user, password)
+ c
+ end
+ let(:server_context) { { msf_client: client, rate_limiter: limiter } }
+
+ before do
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+ end
+
+ describe 'Module Info Integration with HTTP' do
+ it 'retrieves module info through complete HTTP request flow' do
+ info_stub = stub_request(:post, api_url)
+ .with(body: ['module.info', 'test_token', 'exploit', 'windows/smb/ms17_010_eternalblue'].to_msgpack)
+ .to_return(
+ status: 200,
+ body: {
+ 'type' => 'exploit',
+ 'name' => 'MS17-010 EternalBlue',
+ 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue',
+ 'rank' => 'excellent',
+ 'disclosuredate' => '2017-03-14',
+ 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption',
+ 'license' => 'MSF_LICENSE',
+ 'filepath' => '/opt/metasploit-framework/modules/exploits/windows/smb/ms17_010_eternalblue.rb',
+ 'arch' => ['x64', 'x86'],
+ 'platform' => ['windows'],
+ 'authors' => ['Author1', 'Author2'],
+ 'privileged' => true,
+ 'check' => true,
+ 'references' => [['CVE', '2017-0144'], ['URL', 'https://example.com']],
+ 'targets' => { 0 => 'Windows 7', 1 => 'Windows 8' },
+ 'default_target' => 0,
+ 'options' => { 'RHOSTS' => { 'type' => 'address', 'required' => true } }
+ }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ result = Msf::MCP::Tools::ModuleInfo.call(
+ type: 'exploit',
+ name: 'windows/smb/ms17_010_eternalblue',
+ server_context: server_context
+ )
+
+ expect(info_stub).to have_been_requested.once
+
+ expect(result).to be_a(MCP::Tool::Response)
+ expect(result.error?).to be false
+
+ data = result.structured_content[:data]
+ expect(data[:fullname]).to eq('exploit/windows/smb/ms17_010_eternalblue')
+ expect(data[:rank]).to eq('excellent')
+ expect(data[:architectures]).to eq(['x64', 'x86'])
+ expect(data[:has_check_method]).to be true
+
+ # Verify filepath is stripped of install path
+ expect(data[:filepath]).to eq('modules/exploits/windows/smb/ms17_010_eternalblue.rb')
+ expect(data[:filepath]).not_to include('/opt/metasploit-framework/')
+
+ # Verify references are transformed
+ expect(data[:references]).to eq([
+ { type: 'CVE', value: '2017-0144' },
+ { type: 'URL', value: 'https://example.com' }
+ ])
+
+ # Verify metadata
+ expect(result.structured_content[:metadata][:query_time]).to be_a(Float)
+ end
+
+ it 'handles module not found through HTTP' do
+ stub_request(:post, api_url)
+ .with(body: ['module.info', 'test_token', 'exploit', 'nonexistent/module'].to_msgpack)
+ .to_return(
+ status: 500,
+ body: { 'error_message' => 'Module not found' }.to_msgpack
+ )
+
+ result = Msf::MCP::Tools::ModuleInfo.call(
+ type: 'exploit',
+ name: 'nonexistent/module',
+ server_context: server_context
+ )
+
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to include('Metasploit API error')
+ end
+
+ it 'validates module type before making HTTP request' do
+ result = Msf::MCP::Tools::ModuleInfo.call(
+ type: 'invalid_type',
+ name: 'windows/smb/ms17_010_eternalblue',
+ server_context: server_context
+ )
+
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to include('Module type')
+ end
+ end
+end
diff --git a/spec/integration/msfmcpd/tool_execution_search_spec.rb b/spec/integration/msfmcpd/tool_execution_search_spec.rb
new file mode 100644
index 0000000000000..18b05a62aacc9
--- /dev/null
+++ b/spec/integration/msfmcpd/tool_execution_search_spec.rb
@@ -0,0 +1,217 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'webmock/rspec'
+
+RSpec.describe 'Tool Execution End-to-End - Search Modules' do
+ # Disable real HTTP connections for integration tests
+ before(:all) do
+ WebMock.disable_net_connect!(allow_localhost: false)
+ end
+
+ after(:all) do
+ WebMock.allow_net_connect!
+ end
+
+ let(:host) { 'localhost' }
+ let(:port) { 55553 }
+ let(:endpoint) { '/api/' }
+ let(:api_url) { "https://#{host}:#{port}#{endpoint}" }
+ let(:user) { 'test_user' }
+ let(:password) { 'test_password' }
+
+ describe 'Module Search Integration with HTTP' do
+ it 'executes module search through complete HTTP request flow' do
+ # Stub authentication endpoint
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub module search endpoint with realistic response
+ # Note: The MessagePackClient returns the unpacked response directly,
+ # so we need to return an array, not a hash with 'modules' key
+ search_stub = stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'smb'].to_msgpack)
+ .to_return(
+ status: 200,
+ body: [
+ {
+ 'name' => 'ms17_010_eternalblue',
+ 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue',
+ 'type' => 'exploit',
+ 'rank' => 'excellent',
+ 'disclosuredate' => '2017-03-14',
+ 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption'
+ },
+ {
+ 'name' => 'smb_version',
+ 'fullname' => 'auxiliary/scanner/smb/smb_version',
+ 'type' => 'auxiliary',
+ 'rank' => 'normal',
+ 'description' => 'SMB Version Detection'
+ }
+ ].to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Create rate limiter
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10)
+
+ # Create authenticated client
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ # Create server context
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # Execute search through complete stack
+ result = Msf::MCP::Tools::SearchModules.call(
+ query: 'smb',
+ server_context: server_context
+ )
+
+ # Verify HTTP request was made
+ expect(search_stub).to have_been_requested.once
+
+ # Verify MCP response structure
+ expect(result).to be_a(MCP::Tool::Response)
+ expect(result.content).to be_an(Array)
+ expect(result.content.first[:type]).to eq('text')
+
+ # Verify data transformation occurred correctly
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:fullname]).to eq('exploit/windows/smb/ms17_010_eternalblue')
+ expect(data.first[:type]).to eq('exploit')
+
+ # Verify metadata
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:query]).to eq('smb')
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ end
+
+ it 'handles empty search results through complete HTTP flow' do
+ # Stub authentication
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub search with empty results
+ search_stub = stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'nonexistent_module_xyz'].to_msgpack)
+ .to_return(
+ status: 200,
+ body: [].to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10)
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # Execute search
+ result = Msf::MCP::Tools::SearchModules.call(
+ query: 'nonexistent_module_xyz',
+ server_context: server_context
+ )
+
+ # Verify HTTP request was made
+ expect(search_stub).to have_been_requested.once
+
+ # Verify empty results handled correctly
+ expect(result).to be_a(MCP::Tool::Response)
+ expect(result.structured_content[:data]).to eq([])
+ expect(result.structured_content[:metadata][:total_items]).to eq(0)
+ expect(result.structured_content[:metadata][:returned_items]).to eq(0)
+ end
+
+ it 'applies pagination correctly through HTTP request' do
+ # Stub authentication
+ stub_request(:post, api_url)
+ .with(body: ['auth.login', user, password].to_msgpack)
+ .to_return(
+ status: 200,
+ body: { 'result' => 'success', 'token' => 'test_token' }.to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ # Stub search with multiple results
+ search_stub = stub_request(:post, api_url)
+ .with(body: ['module.search', 'test_token', 'scanner'].to_msgpack)
+ .to_return(
+ status: 200,
+ body: [
+ { 'fullname' => 'auxiliary/scanner/http/http_version', 'type' => 'auxiliary', 'name' => 'http_version' },
+ { 'fullname' => 'auxiliary/scanner/smb/smb_version', 'type' => 'auxiliary', 'name' => 'smb_version' },
+ { 'fullname' => 'auxiliary/scanner/ssh/ssh_version', 'type' => 'auxiliary', 'name' => 'ssh_version' },
+ { 'fullname' => 'auxiliary/scanner/ftp/ftp_version', 'type' => 'auxiliary', 'name' => 'ftp_version' }
+ ].to_msgpack,
+ headers: { 'Content-Type' => 'binary/message-pack' }
+ )
+
+ limiter = Msf::MCP::Security::RateLimiter.new(requests_per_minute: 60, burst_size: 10)
+ client = Msf::MCP::Metasploit::MessagePackClient.new(
+ host: host,
+ port: port,
+ endpoint: endpoint
+ )
+ client.authenticate(user, password)
+
+ server_context = {
+ msf_client: client,
+ rate_limiter: limiter
+ }
+
+ # Execute search with pagination
+ result = Msf::MCP::Tools::SearchModules.call(
+ query: 'scanner',
+ limit: 2,
+ offset: 1,
+ server_context: server_context
+ )
+
+ # Verify HTTP request was made
+ expect(search_stub).to have_been_requested.once
+
+ # Verify pagination applied correctly (offset=1, limit=2 means items 2 and 3)
+ expect(result).to be_a(MCP::Tool::Response)
+ data = result.structured_content[:data]
+ expect(data.length).to eq(2)
+ expect(data.first[:fullname]).to eq('auxiliary/scanner/smb/smb_version')
+ expect(data.last[:fullname]).to eq('auxiliary/scanner/ssh/ssh_version')
+
+ # Verify pagination metadata
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(2)
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:total_items]).to eq(4)
+ expect(metadata[:returned_items]).to eq(2)
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/application_spec.rb b/spec/lib/msf/core/mcp/application_spec.rb
new file mode 100644
index 0000000000000..c5bfcc97bc22a
--- /dev/null
+++ b/spec/lib/msf/core/mcp/application_spec.rb
@@ -0,0 +1,873 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'stringio'
+require 'tempfile'
+
+RSpec.describe Msf::MCP::Application do
+ let(:output) { StringIO.new }
+ let(:valid_config) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 55553,
+ ssl: true,
+ endpoint: '/api/',
+ user: 'testuser',
+ password: 'testpass'
+ },
+ mcp: {
+ transport: 'stdio'
+ },
+ rate_limit: {
+ requests_per_minute: 60,
+ burst_size: 10
+ }
+ }
+ end
+
+ describe '#initialize' do
+ it 'initializes with default values' do
+ app = described_class.new([], output: output)
+
+ expect(app.options[:config_path]).to be_nil
+ # Logging options are no longer in defaults, they come from config file
+ expect(app.options[:enable_logging_cli]).to be_nil
+ expect(app.options[:log_file_cli]).to be_nil
+ end
+
+ it 'accepts custom output stream' do
+ # Verify output is used - instantiation doesn't trigger help automatically
+ app = described_class.new([], output: output)
+ expect(app).to be_a(described_class)
+
+ # Test that parse_arguments with --help writes to output
+ help_output = StringIO.new
+ help_app = described_class.new(['--help'], output: help_output)
+ expect { help_app.send(:parse_arguments) }.to raise_error(SystemExit)
+ expect(help_output.string).to include('MSF MCP Server')
+ end
+ end
+
+ describe '#parse_arguments' do
+ it 'parses --config argument' do
+ app = described_class.new(['--config', '/custom/path/config.yml'], output: output)
+ app.send(:parse_arguments)
+
+ expect(app.options[:config_path]).to eq('/custom/path/config.yml')
+ end
+
+ it 'parses --enable-logging argument' do
+ app = described_class.new(['--enable-logging'], output: output)
+ app.send(:parse_arguments)
+
+ expect(app.options[:enable_logging_cli]).to be true
+ end
+
+ it 'parses --log-file argument' do
+ app = described_class.new(['--log-file', 'custom.log'], output: output)
+ app.send(:parse_arguments)
+
+ expect(app.options[:log_file_cli]).to eq('custom.log')
+ end
+
+ it 'exits with help message on --help' do
+ app = described_class.new(['--help'], output: output)
+
+ expect { app.send(:parse_arguments) }.to raise_error(SystemExit) do |error|
+ expect(error.status).to eq(0)
+ end
+ expect(output.string).to include('MSF MCP Server')
+ expect(output.string).to include('Usage:')
+ end
+
+ it 'exits with version on --version' do
+ app = described_class.new(['--version'], output: output)
+
+ expect { app.send(:parse_arguments) }.to raise_error(SystemExit) do |error|
+ expect(error.status).to eq(0)
+ end
+ expect(output.string).to include('msfmcp version')
+ end
+
+ it 'accepts short form -h for help' do
+ app = described_class.new(['-h'], output: output)
+
+ expect { app.send(:parse_arguments) }.to raise_error(SystemExit)
+ expect(output.string).to include('MSF MCP Server')
+ end
+
+ it 'accepts short form -v for version' do
+ app = described_class.new(['-v'], output: output)
+
+ expect { app.send(:parse_arguments) }.to raise_error(SystemExit)
+ expect(output.string).to include('msfmcp version')
+ end
+
+ it 'parses --no-auto-start-rpc argument' do
+ app = described_class.new(['--no-auto-start-rpc'], output: output)
+ app.send(:parse_arguments)
+
+ expect(app.options[:no_auto_start_rpc]).to be true
+ end
+
+ it 'does not set no_auto_start_rpc by default' do
+ app = described_class.new([], output: output)
+ app.send(:parse_arguments)
+
+ expect(app.options[:no_auto_start_rpc]).to be_nil
+ end
+ end
+
+ describe '#initialize_logger' do
+ let(:log_file) { Tempfile.new('app_test_log').tap(&:close).path }
+
+ after do
+ if log_source_registered?(Msf::MCP::LOG_SOURCE)
+ deregister_log_source(Msf::MCP::LOG_SOURCE)
+ end
+ File.delete(log_file) if File.exist?(log_file)
+ end
+
+ it 'does not register a Rex source when logging is disabled' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, {})
+ app.send(:initialize_logger)
+
+ expect(log_source_registered?(Msf::MCP::LOG_SOURCE)).to be false
+ end
+
+ it 'registers the Rex source when --enable-logging is set' do
+ app = described_class.new(['--enable-logging', '--log-file', log_file], output: output)
+ app.send(:parse_arguments)
+ app.instance_variable_set(:@config, { logging: { enabled: false, level: 'INFO', sanitize: false } })
+ app.send(:initialize_logger)
+
+ expect(log_source_registered?(Msf::MCP::LOG_SOURCE)).to be true
+ end
+
+ it 'registers the Rex source when logging.enabled is true in config' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, { logging: { enabled: true, level: 'INFO', log_file: log_file, sanitize: false } })
+ app.send(:initialize_logger)
+
+ expect(log_source_registered?(Msf::MCP::LOG_SOURCE)).to be true
+ end
+
+ it 'uses CLI log file path over config file path' do
+ cli_log = Tempfile.new('cli_log').tap(&:close).path
+ app = described_class.new(['--log-file', cli_log], output: output)
+ app.send(:parse_arguments)
+ app.instance_variable_set(:@config, { logging: { enabled: true, level: 'INFO', log_file: log_file, sanitize: false } })
+ app.send(:initialize_logger)
+
+ # Emit a message and confirm it went to the CLI path, not the config path
+ ilog('probe', Msf::MCP::LOG_SOURCE, Rex::Logging::LEV_0)
+ expect(File.read(cli_log)).to include('probe')
+ expect(File.read(log_file)).to be_empty
+
+ deregister_log_source(Msf::MCP::LOG_SOURCE)
+ File.delete(cli_log) if File.exist?(cli_log)
+ end
+
+ it 'wraps the sink with Sanitizing when sanitize is true' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, { logging: { enabled: true, level: 'INFO', log_file: log_file, sanitize: true } })
+ app.send(:initialize_logger)
+
+ # Log a message containing a sensitive pattern and verify it is redacted
+ elog({ message: 'password= s3cret' }, Msf::MCP::LOG_SOURCE, Rex::Logging::LEV_0)
+ content = File.read(log_file)
+ expect(content).to include('[REDACTED]')
+ expect(content).not_to include('s3cret')
+ end
+
+ it 'does not wrap with Sanitizing when sanitize is false' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, { logging: { enabled: true, level: 'INFO', log_file: log_file, sanitize: false } })
+ app.send(:initialize_logger)
+
+ # Log a message containing a sensitive pattern — should appear as-is
+ elog({ message: 'password= s3cret' }, Msf::MCP::LOG_SOURCE, Rex::Logging::LEV_0)
+ content = File.read(log_file)
+ expect(content).to include('s3cret')
+ end
+ end
+
+ describe '#install_signal_handlers' do
+ it 'installs signal handlers for INT and TERM' do
+ app = described_class.new([], output: output)
+
+ # Mock Signal.trap to avoid actually installing handlers in tests
+ expect(Signal).to receive(:trap).with('INT')
+ expect(Signal).to receive(:trap).with('TERM')
+
+ app.send(:install_signal_handlers)
+ end
+ end
+
+ describe '#load_configuration' do
+ it 'loads configuration from file' do
+ config_file = Tempfile.new(['config', '.yml'])
+ # Dirty hack to make sure the config hash keys are strings and not symbols.
+ config_file.write(YAML.dump(JSON.parse(valid_config.to_json)))
+ config_file.flush
+
+ app = described_class.new(['--config', config_file.path], output: output)
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+
+ expect(app.config).to be_a(Hash)
+ expect(app.config[:msf_api][:type]).to eq('messagepack')
+
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'outputs loading message' do
+ config_file = Tempfile.new(['config', '.yml'])
+ # Dirty hack to make sure the config hash keys are strings and not symbols.
+ config_file.write(YAML.dump(JSON.parse(valid_config.to_json)))
+ config_file.flush
+
+ app = described_class.new(['--config', config_file.path], output: output)
+ app.send(:parse_arguments)
+ app.send(:load_configuration)
+
+ expect(output.string).to include("Loading configuration from #{config_file.path}")
+
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'raises error for missing file' do
+ app = described_class.new(['--config', '/nonexistent/config.yml'], output: output)
+ app.send(:parse_arguments)
+
+ expect { app.send(:load_configuration) }.to raise_error(Msf::MCP::Config::ConfigurationError, /not found/)
+ end
+ end
+
+ describe '#validate_configuration' do
+ it 'validates configuration successfully' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+
+ expect { app.send(:validate_configuration) }.not_to raise_error
+ expect(output.string).to include('Validating configuration...')
+ expect(output.string).to include('Configuration valid')
+ end
+
+ it 'raises error for invalid configuration' do
+ app = described_class.new([], output: output)
+ # Use a config with an actual validation error (invalid enum value)
+ app.instance_variable_set(:@config, { msf_api: { type: 'invalid_type' } })
+
+ expect { app.send(:validate_configuration) }.to raise_error(Msf::MCP::Config::ValidationError)
+ end
+ end
+
+ describe '#initialize_rate_limiter' do
+ it 'creates rate limiter with config values' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+ app.send(:initialize_rate_limiter)
+
+ expect(app.rate_limiter).to be_a(Msf::MCP::Security::RateLimiter)
+ expect(app.rate_limiter.instance_variable_get(:@requests_per_minute)).to eq(60)
+ end
+
+ it 'uses default values when rate_limit config is missing' do
+ config_without_rate_limit = valid_config.dup
+ config_without_rate_limit.delete(:rate_limit)
+
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, config_without_rate_limit)
+ app.send(:initialize_rate_limiter)
+
+ expect(app.rate_limiter).to be_a(Msf::MCP::Security::RateLimiter)
+ expect(app.rate_limiter.instance_variable_get(:@requests_per_minute)).to eq(60)
+ end
+ end
+
+ describe '#initialize_metasploit_client' do
+ it 'creates Metasploit client with config values' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+
+ # Mock the Client.new to avoid actual connection
+ mock_client = instance_double(Msf::MCP::Metasploit::Client)
+ expect(Msf::MCP::Metasploit::Client).to receive(:new).with(
+ api_type: 'messagepack',
+ host: 'localhost',
+ port: 55553,
+ ssl: true,
+ endpoint: '/api/',
+ token: nil
+ ).and_return(mock_client)
+
+ app.send(:initialize_metasploit_client)
+
+ expect(app.msf_client).to eq(mock_client)
+ expect(output.string).to include('Connecting to Metasploit RPC at localhost:55553')
+ end
+ end
+
+ describe '#authenticate_metasploit' do
+ let(:mock_client) { instance_double(Msf::MCP::Metasploit::Client) }
+
+ before do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+ app.instance_variable_set(:@msf_client, mock_client)
+ end
+
+ it 'authenticates when using MessagePack' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+ app.instance_variable_set(:@msf_client, mock_client)
+
+ expect(mock_client).to receive(:authenticate).with('testuser', 'testpass')
+
+ app.send(:authenticate_metasploit)
+
+ expect(output.string).to include('Authenticating with Metasploit...')
+ expect(output.string).to include('Authentication successful')
+ end
+
+ it 'skips authentication when using JSON-RPC' do
+ json_rpc_config = valid_config.dup
+ json_rpc_config[:msf_api][:type] = 'json-rpc'
+ json_rpc_config[:msf_api][:token] = 'test_token'
+
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, json_rpc_config)
+ app.instance_variable_set(:@msf_client, mock_client)
+
+ expect(mock_client).not_to receive(:authenticate)
+
+ app.send(:authenticate_metasploit)
+
+ expect(output.string).to include('Using JSON-RPC with token authentication')
+ end
+ end
+
+ describe '#initialize_mcp_server' do
+ it 'creates MCP server with dependencies' do
+ mock_client = instance_double(Msf::MCP::Metasploit::Client)
+ mock_rate_limiter = instance_double(Msf::MCP::Security::RateLimiter)
+ mock_mcp_server = instance_double(Msf::MCP::Server)
+
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@msf_client, mock_client)
+ app.instance_variable_set(:@rate_limiter, mock_rate_limiter)
+
+ expect(Msf::MCP::Server).to receive(:new).with(
+ msf_client: mock_client,
+ rate_limiter: mock_rate_limiter
+ ).and_return(mock_mcp_server)
+
+ app.send(:initialize_mcp_server)
+
+ expect(app.mcp_server).to eq(mock_mcp_server)
+ expect(output.string).to include('Initializing MCP server...')
+ end
+ end
+
+ describe '#start_mcp_server' do
+ let(:mock_mcp_server) { instance_double(Msf::MCP::Server) }
+
+ it 'starts server with stdio transport by default' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+ app.instance_variable_set(:@mcp_server, mock_mcp_server)
+
+ expect(mock_mcp_server).to receive(:start).with(transport: :stdio)
+
+ app.send(:start_mcp_server)
+
+ expect(output.string).to include('Starting MCP server on stdio transport...')
+ expect(output.string).to include('Server ready - waiting for MCP requests')
+ end
+
+ it 'starts server with HTTP transport when configured' do
+ http_config = valid_config.dup
+ http_config[:mcp] = {
+ transport: 'http',
+ host: '0.0.0.0',
+ port: 3000
+ }
+
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, http_config)
+ app.instance_variable_set(:@mcp_server, mock_mcp_server)
+
+ expect(mock_mcp_server).to receive(:start).with(transport: :http, host: '0.0.0.0', port: 3000)
+
+ 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')
+ end
+
+ it 'uses default host and port for HTTP transport' do
+ http_config = valid_config.dup
+ http_config[:mcp] = { transport: 'http' }
+
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, http_config)
+ app.instance_variable_set(:@mcp_server, mock_mcp_server)
+
+ expect(mock_mcp_server).to receive(:start).with(transport: :http, host: 'localhost', port: 3000)
+
+ app.send(:start_mcp_server)
+ end
+ end
+
+ describe '#shutdown' do
+ it 'outputs shutdown complete' do
+ app = described_class.new([], output: output)
+ app.shutdown('INT')
+
+ expect(output.string).to include('Shutdown complete')
+ end
+
+ it 'does not raise when no Rex sink is registered' do
+ app = described_class.new([], output: output)
+ expect { app.shutdown('TERM') }.not_to raise_error
+ end
+
+ it 'calls shutdown on mcp_server when present' do
+ mock_mcp_server = instance_double(Msf::MCP::Server)
+
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@mcp_server, mock_mcp_server)
+
+ expect(mock_mcp_server).to receive(:shutdown)
+
+ app.shutdown('INT')
+
+ expect(output.string).to include('Shutdown complete')
+ end
+
+ it 'calls stop_rpc_server on rpc_manager when present' do
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@rpc_manager, mock_rpc_manager)
+
+ expect(mock_rpc_manager).to receive(:stop_rpc_server)
+
+ app.shutdown('INT')
+ end
+
+ it 'handles nil mcp_server gracefully' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@mcp_server, nil)
+
+ expect { app.shutdown('INT') }.not_to raise_error
+ expect(output.string).to include('Shutdown complete')
+ end
+
+ it 'handles nil rpc_manager gracefully' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@rpc_manager, nil)
+
+ expect { app.shutdown('INT') }.not_to raise_error
+ expect(output.string).to include('Shutdown complete')
+ end
+ end
+
+ describe '#ensure_rpc_server' do
+ context 'when RPC is already available' do
+ it 'does not create an RPC manager' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available)
+
+ app.send(:ensure_rpc_server)
+
+ expect(app.rpc_manager).to eq(mock_rpc_manager)
+ end
+
+ it 'calls ensure_rpc_available on the manager' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+
+ expect(mock_rpc_manager).to receive(:ensure_rpc_available)
+
+ app.send(:ensure_rpc_server)
+ end
+ end
+
+ context 'when --no-auto-start-rpc is set' do
+ it 'sets auto_start_rpc to false in config during load_configuration' do
+ app = described_class.new(['--no-auto-start-rpc'], output: output)
+ app.send(:parse_arguments)
+ app.instance_variable_set(:@config, valid_config.dup)
+
+ # Simulate load_configuration CLI override
+ app.send(:load_configuration) rescue nil
+ # Directly verify the config was updated by setting it up properly
+ app = described_class.new(['--no-auto-start-rpc'], output: output)
+ app.send(:parse_arguments)
+ config = valid_config.dup
+ config[:msf_api] = config[:msf_api].dup
+ app.instance_variable_set(:@config, config)
+
+ # Apply the override as load_configuration would
+ config[:msf_api][:auto_start_rpc] = false if app.options[:no_auto_start_rpc]
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available)
+
+ expect(Msf::MCP::RpcManager).to receive(:new).with(
+ hash_including(config: hash_including(msf_api: hash_including(auto_start_rpc: false)))
+ ).and_return(mock_rpc_manager)
+
+ app.send(:ensure_rpc_server)
+ end
+ end
+
+ context 'when RPC startup fails' do
+ it 'propagates RpcStartupError' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available).and_raise(
+ Msf::MCP::Metasploit::RpcStartupError.new('msfrpcd not found')
+ )
+
+ expect { app.send(:ensure_rpc_server) }.to raise_error(Msf::MCP::Metasploit::RpcStartupError)
+ end
+ end
+
+ context 'when connection times out' do
+ it 'propagates ConnectionError' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available).and_raise(
+ Msf::MCP::Metasploit::ConnectionError.new('Timed out waiting for RPC server')
+ )
+
+ expect { app.send(:ensure_rpc_server) }.to raise_error(Msf::MCP::Metasploit::ConnectionError)
+ end
+ end
+
+ it 'passes output to RpcManager' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available)
+
+ expect(Msf::MCP::RpcManager).to receive(:new).with(
+ hash_including(output: output)
+ ).and_return(mock_rpc_manager)
+
+ app.send(:ensure_rpc_server)
+ end
+ end
+
+ describe 'error handlers' do
+ describe '#handle_configuration_error' do
+ it 'outputs error message and exits' do
+ app = described_class.new([], output: output)
+ error = Msf::MCP::Config::ValidationError.new({ 'msf_api.type': 'is invalid' })
+
+ expect { app.send(:handle_configuration_error, error) }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+ expect(output.string).to include('Configuration validation failed')
+ end
+
+ it 'handles ConfigurationError the same way' do
+ app = described_class.new([], output: output)
+ error = Msf::MCP::Config::ConfigurationError.new('Configuration file not found: /missing.yml')
+
+ expect { app.send(:handle_configuration_error, error) }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+ expect(output.string).to include('Configuration file not found')
+ end
+
+ it 'does not call elog (logger not available yet)' do
+ app = described_class.new([], output: output)
+ error = Msf::MCP::Config::ValidationError.new({})
+
+ # elog should not be called — logger is not initialized at this stage
+ expect(app).not_to receive(:elog)
+ expect { app.send(:handle_configuration_error, error) }.to raise_error(SystemExit)
+ end
+ end
+
+ describe '#handle_connection_error' do
+ it 'outputs connection error with host and port' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+ error = Msf::MCP::Metasploit::ConnectionError.new('Connection refused')
+
+ expect { app.send(:handle_connection_error, error) }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+ expect(output.string).to include('Connection error to Metasploit RPC at localhost:55553')
+ expect(output.string).to include('Connection refused')
+ end
+
+ it 'logs the error via elog' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+ error = Msf::MCP::Metasploit::ConnectionError.new('Connection refused')
+
+ expect(app).to receive(:elog).with(hash_including(message: 'Connection error'), anything, anything)
+ expect { app.send(:handle_connection_error, error) }.to raise_error(SystemExit)
+ end
+ end
+
+ describe '#handle_api_error' do
+ it 'outputs API error message' do
+ app = described_class.new([], output: output)
+ error = Msf::MCP::Metasploit::APIError.new('Invalid method')
+
+ expect { app.send(:handle_api_error, error) }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+ expect(output.string).to include('Metasploit API error: Invalid method')
+ end
+
+ it 'logs the error via elog' do
+ app = described_class.new([], output: output)
+ error = Msf::MCP::Metasploit::APIError.new('Invalid method')
+
+ expect(app).to receive(:elog).with(hash_including(message: 'Metasploit API error'), anything, anything)
+ expect { app.send(:handle_api_error, error) }.to raise_error(SystemExit)
+ end
+ end
+
+ describe '#handle_authentication_error' do
+ it 'outputs authentication error with username' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+ error = Msf::MCP::Metasploit::AuthenticationError.new('Login Failed')
+
+ expect { app.send(:handle_authentication_error, error) }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+ expect(output.string).to include('Authentication error (username: testuser): Login Failed')
+ end
+
+ it 'logs the error via elog' do
+ app = described_class.new([], output: output)
+ app.instance_variable_set(:@config, valid_config)
+ error = Msf::MCP::Metasploit::AuthenticationError.new('Login Failed')
+
+ expect(app).to receive(:elog).with(hash_including(message: 'Authentication error'), anything, anything)
+ expect { app.send(:handle_authentication_error, error) }.to raise_error(SystemExit)
+ end
+ end
+
+ describe '#handle_rpc_startup_error' do
+ it 'outputs RPC startup error message and exits' do
+ app = described_class.new([], output: output)
+ error = Msf::MCP::Metasploit::RpcStartupError.new('msfrpcd not found')
+
+ expect { app.send(:handle_rpc_startup_error, error) }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+ expect(output.string).to include('RPC startup error: msfrpcd not found')
+ end
+
+ it 'logs the error via elog' do
+ app = described_class.new([], output: output)
+ error = Msf::MCP::Metasploit::RpcStartupError.new('msfrpcd not found')
+
+ expect(app).to receive(:elog).with(hash_including(message: 'RPC startup error'), anything, anything)
+ expect { app.send(:handle_rpc_startup_error, error) }.to raise_error(SystemExit)
+ end
+ end
+
+ describe '#handle_fatal_error' do
+ it 'outputs error message and backtrace' do
+ app = described_class.new([], output: output)
+ error = StandardError.new('Unexpected error')
+ error.set_backtrace(['line1', 'line2', 'line3', 'line4', 'line5', 'line6'])
+
+ expect { app.send(:handle_fatal_error, error) }.to raise_error(SystemExit) do |e|
+ expect(e.status).to eq(1)
+ end
+ expect(output.string).to include('Fatal error: Unexpected error')
+ expect(output.string).to include('line1')
+ expect(output.string).to include('line5')
+ expect(output.string).not_to include('line6') # Only first 5 lines
+ end
+
+ it 'logs the error via elog' do
+ app = described_class.new([], output: output)
+ error = StandardError.new('Unexpected error')
+
+ expect(app).to receive(:elog).with(hash_including(message: 'Fatal error during startup'), anything, anything)
+ expect { app.send(:handle_fatal_error, error) }.to raise_error(SystemExit)
+ end
+ end
+ end
+
+ describe '#run' do
+ let(:config_file) { Tempfile.new(['config', '.yml']) }
+ let(:mock_mcp_server) { instance_double(Msf::MCP::Server) }
+ let(:mock_client) { instance_double(Msf::MCP::Metasploit::Client) }
+
+ before do
+ # Dirty hack to make sure the config hash keys are strings and not symbols.
+ config_file.write(YAML.dump(JSON.parse(valid_config.to_json)))
+ config_file.flush
+ end
+
+ after do
+ config_file.close
+ config_file.unlink
+ end
+
+ it 'runs through the complete startup sequence successfully' do
+ app = described_class.new(['--config', config_file.path], output: output)
+
+ # Mock RPC manager
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available)
+
+ # Mock external dependencies
+ allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client)
+ allow(mock_client).to receive(:authenticate)
+ allow(Msf::MCP::Server).to receive(:new).and_return(mock_mcp_server)
+ allow(mock_mcp_server).to receive(:start)
+
+ # Mock signal handlers to avoid actual installation
+ allow(Signal).to receive(:trap)
+
+ app.run
+
+ # Verify all initialization steps occurred
+ expect(output.string).to include('Loading configuration')
+ expect(output.string).to include('Validating configuration')
+ expect(output.string).to include('Configuration valid')
+ expect(output.string).to include('Connecting to Metasploit RPC')
+ expect(output.string).to include('Authenticating with Metasploit')
+ expect(output.string).to include('Authentication successful')
+ expect(output.string).to include('Initializing MCP server')
+ expect(output.string).to include('Starting MCP server')
+ end
+
+ it 'handles configuration errors gracefully' do
+ bad_config = { msf_api: {} }
+ config_file.rewind
+ # Dirty hack to make sure the config hash keys are strings and not symbols.
+ config_file.write(YAML.dump(JSON.parse(bad_config.to_json)))
+ config_file.flush
+
+ app = described_class.new(['--config', config_file.path], output: output)
+
+ expect { app.run }.to raise_error(SystemExit) do |error|
+ expect(error.status).to eq(1)
+ end
+ expect(output.string).to include('Configuration validation failed')
+ end
+
+ it 'handles missing config file gracefully' do
+ app = described_class.new(['--config', '/nonexistent/config.yml'], output: output)
+
+ expect { app.run }.to raise_error(SystemExit) do |error|
+ expect(error.status).to eq(1)
+ end
+ expect(output.string).to include('Configuration file not found')
+ end
+
+ it 'handles authentication errors gracefully' do
+ app = described_class.new(['--config', config_file.path], output: output)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available)
+
+ allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client)
+ allow(mock_client).to receive(:authenticate).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Login Failed')
+ )
+ allow(Signal).to receive(:trap)
+
+ expect { app.run }.to raise_error(SystemExit) do |error|
+ expect(error.status).to eq(1)
+ end
+ expect(output.string).to include('Authentication error')
+ expect(output.string).to include('Login Failed')
+ end
+
+ it 'handles connection errors gracefully' do
+ app = described_class.new(['--config', config_file.path], output: output)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available)
+
+ allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client)
+ allow(mock_client).to receive(:authenticate).and_raise(
+ Msf::MCP::Metasploit::ConnectionError.new('Connection refused')
+ )
+ allow(Signal).to receive(:trap)
+
+ expect { app.run }.to raise_error(SystemExit) do |error|
+ expect(error.status).to eq(1)
+ end
+ expect(output.string).to include('Connection error')
+ end
+
+ it 'handles RPC startup errors gracefully' do
+ app = described_class.new(['--config', config_file.path], output: output)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available).and_raise(
+ Msf::MCP::Metasploit::RpcStartupError.new('msfrpcd not found')
+ )
+ allow(Signal).to receive(:trap)
+
+ expect { app.run }.to raise_error(SystemExit) do |error|
+ expect(error.status).to eq(1)
+ end
+ end
+
+ it 'includes ensure_rpc_server in the run sequence before initialize_metasploit_client' do
+ app = described_class.new(['--config', config_file.path], output: output)
+
+ mock_rpc_manager = instance_double(Msf::MCP::RpcManager)
+ allow(Msf::MCP::RpcManager).to receive(:new).and_return(mock_rpc_manager)
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available)
+
+ allow(Msf::MCP::Metasploit::Client).to receive(:new).and_return(mock_client)
+ allow(mock_client).to receive(:authenticate)
+ allow(Msf::MCP::Server).to receive(:new).and_return(mock_mcp_server)
+ allow(mock_mcp_server).to receive(:start)
+ allow(Signal).to receive(:trap)
+
+ # Track the order of operations
+ order = []
+ allow(mock_rpc_manager).to receive(:ensure_rpc_available) { order << :ensure_rpc }
+ allow(Msf::MCP::Metasploit::Client).to receive(:new) { order << :init_client; mock_client }
+
+ app.run
+
+ expect(order).to eq([:ensure_rpc, :init_client])
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/config/loader_spec.rb b/spec/lib/msf/core/mcp/config/loader_spec.rb
new file mode 100644
index 0000000000000..8f78a03819300
--- /dev/null
+++ b/spec/lib/msf/core/mcp/config/loader_spec.rb
@@ -0,0 +1,841 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'tempfile'
+
+RSpec.describe Msf::MCP::Config::Loader do
+ let(:file_fixtures_path) { File.join(Msf::Config.install_root, 'spec', 'file_fixtures') }
+
+ describe '.load' do
+ context 'with valid YAML file' do
+ let(:config_file) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_messagepack.yaml') }
+
+ it 'loads configuration from file' do
+ config = described_class.load(config_file)
+
+ expect(config).to be_a(Hash)
+ expect(config[:msf_api][:type]).to eq('messagepack')
+ expect(config[:msf_api][:host]).to eq('localhost')
+ end
+
+ it 'returns configuration with symbolized keys' do
+ config = described_class.load(config_file)
+
+ expect(config.keys).to all(be_a(Symbol))
+ end
+ end
+
+ context 'with file not found' do
+ it 'raises ConfigurationError with descriptive message' do
+ expect {
+ described_class.load('/nonexistent/config.yaml')
+ }.to raise_error(Msf::MCP::Config::ConfigurationError, /not found/)
+ end
+ end
+
+ context 'with invalid YAML syntax' do
+ let(:invalid_yaml_file) { Tempfile.new(['invalid', '.yaml']) }
+
+ before do
+ invalid_yaml_file.write("invalid: yaml: content:\n - unbalanced")
+ invalid_yaml_file.flush
+ end
+
+ after do
+ invalid_yaml_file.close
+ invalid_yaml_file.unlink
+ end
+
+ it 'raises ConfigurationError with YAML error details' do
+ expect {
+ described_class.load(invalid_yaml_file.path)
+ }.to raise_error(Msf::MCP::Config::ConfigurationError, /Invalid YAML syntax/)
+ end
+ end
+
+ context 'with non-hash YAML content' do
+ let(:array_yaml_file) { Tempfile.new(['array', '.yaml']) }
+
+ before do
+ array_yaml_file.write("- item1\n- item2\n- item3")
+ array_yaml_file.flush
+ end
+
+ after do
+ array_yaml_file.close
+ array_yaml_file.unlink
+ end
+
+ it 'raises ConfigurationError requiring hash/dictionary' do
+ expect {
+ described_class.load(array_yaml_file.path)
+ }.to raise_error(Msf::MCP::Config::ConfigurationError, /must contain a YAML hash/)
+ end
+ end
+ end
+
+ describe '.load_from_hash' do
+ context 'with minimal configuration' do
+ let(:config_hash) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'pass'
+ }
+ }
+ end
+
+ it 'applies default values' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:msf_api][:port]).to eq(55553)
+ expect(config[:msf_api][:endpoint]).to eq('/api/')
+ expect(config[:mcp][:transport]).to eq('stdio')
+ end
+
+ it 'applies rate limit defaults' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:rate_limit][:enabled]).to be true
+ expect(config[:rate_limit][:requests_per_minute]).to eq(60)
+ expect(config[:rate_limit][:burst_size]).to eq(10)
+ end
+
+ it 'applies logging defaults' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:logging][:enabled]).to be false
+ expect(config[:logging][:level]).to eq('INFO')
+ end
+ end
+
+ context 'with JSON-RPC configuration' do
+ let(:config_hash) do
+ {
+ msf_api: {
+ type: 'json-rpc',
+ host: 'localhost',
+ token: 'token123'
+ }
+ }
+ end
+
+ it 'applies JSON-RPC default port' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:msf_api][:port]).to eq(8081)
+ end
+
+ it 'applies JSON-RPC default endpoint' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:msf_api][:endpoint]).to eq('/api/v1/json-rpc')
+ end
+ end
+
+ context 'with partial defaults override' do
+ let(:config_hash) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 9999,
+ user: 'msf',
+ password: 'pass'
+ },
+ rate_limit: { requests_per_minute: 120 }
+ }
+ end
+
+ it 'preserves explicit port setting' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:msf_api][:port]).to eq(9999)
+ end
+
+ it 'merges rate limit defaults with provided values' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:rate_limit][:requests_per_minute]).to eq(120)
+ expect(config[:rate_limit][:enabled]).to be true
+ expect(config[:rate_limit][:burst_size]).to eq(10)
+ end
+ end
+
+ context 'with disabled rate limiting' do
+ let(:config_hash) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'pass'
+ },
+ rate_limit: { enabled: false }
+ }
+ end
+
+ it 'respects disabled rate limiting' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:rate_limit][:enabled]).to be false
+ end
+ end
+ end
+
+ describe 'default values' do
+ context 'with completely empty configuration' do
+ let(:config_hash) { {} }
+
+ it 'creates all nested configuration hashes' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:msf_api]).to be_a(Hash)
+ expect(config[:mcp]).to be_a(Hash)
+ expect(config[:rate_limit]).to be_a(Hash)
+ expect(config[:logging]).to be_a(Hash)
+ end
+ end
+
+ context 'MSF API defaults' do
+ let(:config_hash) { {} }
+
+ it 'sets default type to messagepack' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:type]).to eq('messagepack')
+ end
+
+ it 'sets default host to localhost' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:host]).to eq('localhost')
+ end
+
+ it 'sets default port to 55553 for messagepack' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:port]).to eq(55553)
+ end
+
+ it 'sets default ssl to true' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:ssl]).to be true
+ end
+
+ it 'sets default endpoint to /api/ for messagepack' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:endpoint]).to eq('/api/')
+ end
+
+ it 'sets default auto_start_rpc to true' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:auto_start_rpc]).to be true
+ end
+
+ context 'when type is json-rpc' do
+ let(:config_hash) { { msf_api: { type: 'json-rpc' } } }
+
+ it 'sets default port to 8081' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:port]).to eq(8081)
+ end
+
+ it 'sets default endpoint to /api/v1/json-rpc' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:endpoint]).to eq('/api/v1/json-rpc')
+ end
+ end
+
+ context 'when ssl is explicitly set to false' do
+ let(:config_hash) { { msf_api: { ssl: false } } }
+
+ it 'preserves explicit false value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:ssl]).to be false
+ end
+ end
+
+ context 'when ssl is explicitly set to true' do
+ let(:config_hash) { { msf_api: { ssl: true } } }
+
+ it 'preserves explicit true value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:ssl]).to be true
+ end
+ end
+
+ context 'when auto_start_rpc is explicitly set to false' do
+ let(:config_hash) { { msf_api: { auto_start_rpc: false } } }
+
+ it 'preserves explicit false value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:auto_start_rpc]).to be false
+ end
+ end
+
+ context 'when auto_start_rpc is explicitly set to true' do
+ let(:config_hash) { { msf_api: { auto_start_rpc: true } } }
+
+ it 'preserves explicit true value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:msf_api][:auto_start_rpc]).to be true
+ end
+ end
+ end
+
+ context 'MCP defaults' do
+ let(:config_hash) { {} }
+
+ it 'sets default transport to stdio' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:mcp][:transport]).to eq('stdio')
+ end
+
+ context 'with stdio transport' do
+ let(:config_hash) { { mcp: { transport: 'stdio' } } }
+
+ it 'does not set host for stdio transport' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:mcp][:host]).to be_nil
+ end
+
+ it 'does not set port for stdio transport' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:mcp][:port]).to be_nil
+ end
+ end
+
+ context 'with http transport' do
+ let(:config_hash) { { mcp: { transport: 'http' } } }
+
+ it 'sets default host to localhost' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:mcp][:host]).to eq('localhost')
+ end
+
+ it 'sets default port to 3000' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:mcp][:port]).to eq(3000)
+ end
+ end
+
+ context 'with http transport and explicit values' do
+ let(:config_hash) do
+ {
+ mcp: {
+ transport: 'http',
+ host: '0.0.0.0',
+ port: 8080
+ }
+ }
+ end
+
+ it 'preserves explicit host value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:mcp][:host]).to eq('0.0.0.0')
+ end
+
+ it 'preserves explicit port value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:mcp][:port]).to eq(8080)
+ end
+ end
+ end
+
+ context 'rate limit defaults' do
+ let(:config_hash) { {} }
+
+ it 'sets default enabled to true' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:rate_limit][:enabled]).to be true
+ end
+
+ it 'sets default requests_per_minute to 60' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:rate_limit][:requests_per_minute]).to eq(60)
+ end
+
+ it 'sets default burst_size to 10' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:rate_limit][:burst_size]).to eq(10)
+ end
+
+ context 'when rate_limit is explicitly disabled' do
+ let(:config_hash) { { rate_limit: { enabled: false } } }
+
+ it 'preserves explicit false value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:rate_limit][:enabled]).to be false
+ end
+
+ it 'still applies other defaults' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:rate_limit][:requests_per_minute]).to eq(60)
+ expect(config[:rate_limit][:burst_size]).to eq(10)
+ end
+ end
+
+ context 'with partial rate_limit configuration' do
+ let(:config_hash) { { rate_limit: { requests_per_minute: 100 } } }
+
+ it 'applies defaults for missing values' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:rate_limit][:enabled]).to be true
+ expect(config[:rate_limit][:burst_size]).to eq(10)
+ end
+
+ it 'preserves explicit values' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:rate_limit][:requests_per_minute]).to eq(100)
+ end
+ end
+ end
+
+ context 'logging defaults' do
+ let(:config_hash) { {} }
+ let(:default_log_file) { File.join(Msf::Config.log_directory, 'msfmcp.log') }
+
+ it 'sets default enabled to false' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:enabled]).to be false
+ end
+
+ it 'sets default level to INFO' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:level]).to eq('INFO')
+ end
+
+ it 'sets default log_file to msfmcp.log in the default Msf log directory' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:log_file]).to eq(default_log_file)
+ end
+
+ it 'sets default sanitize to true' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:sanitize]).to be true
+ end
+
+ context 'when logging is explicitly enabled' do
+ let(:config_hash) { { logging: { enabled: true } } }
+
+ it 'preserves explicit true value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:enabled]).to be true
+ end
+
+ it 'still applies other defaults' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:level]).to eq('INFO')
+ expect(config[:logging][:log_file]).to eq(default_log_file)
+ expect(config[:logging][:sanitize]).to be true
+ end
+ end
+
+ context 'when sanitize is explicitly set to false' do
+ let(:config_hash) { { logging: { sanitize: false } } }
+
+ it 'preserves explicit false value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:sanitize]).to be false
+ end
+ end
+
+ context 'when sanitize is explicitly set to true' do
+ let(:config_hash) { { logging: { sanitize: true } } }
+
+ it 'preserves explicit true value' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:sanitize]).to be true
+ end
+ end
+
+ context 'with partial logging configuration' do
+ let(:config_hash) { { logging: { level: 'DEBUG', log_file: 'custom.log' } } }
+
+ it 'applies default for enabled' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:enabled]).to be false
+ end
+
+ it 'applies default for sanitize' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:sanitize]).to be true
+ end
+
+ it 'preserves explicit values' do
+ config = described_class.load_from_hash(config_hash)
+ expect(config[:logging][:level]).to eq('DEBUG')
+ expect(config[:logging][:log_file]).to eq('custom.log')
+ end
+ end
+ end
+
+ context 'preserving existing values' do
+ let(:config_hash) do
+ {
+ msf_api: {
+ type: 'json-rpc',
+ host: 'remote.example.com',
+ port: 9000,
+ ssl: false,
+ endpoint: '/custom/api/',
+ token: 'custom_token'
+ },
+ mcp: {
+ transport: 'http',
+ host: '192.168.1.100',
+ port: 5000
+ },
+ rate_limit: {
+ enabled: false,
+ requests_per_minute: 120,
+ burst_size: 20
+ },
+ logging: {
+ enabled: true,
+ level: 'DEBUG',
+ log_file: 'debug.log'
+ }
+ }
+ end
+
+ it 'does not override any explicitly set values' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:msf_api][:type]).to eq('json-rpc')
+ expect(config[:msf_api][:host]).to eq('remote.example.com')
+ expect(config[:msf_api][:port]).to eq(9000)
+ expect(config[:msf_api][:ssl]).to be false
+ expect(config[:msf_api][:endpoint]).to eq('/custom/api/')
+ expect(config[:msf_api][:token]).to eq('custom_token')
+
+ expect(config[:mcp][:transport]).to eq('http')
+ expect(config[:mcp][:host]).to eq('192.168.1.100')
+ expect(config[:mcp][:port]).to eq(5000)
+
+ expect(config[:rate_limit][:enabled]).to be false
+ expect(config[:rate_limit][:requests_per_minute]).to eq(120)
+ expect(config[:rate_limit][:burst_size]).to eq(20)
+
+ expect(config[:logging][:enabled]).to be true
+ expect(config[:logging][:level]).to eq('DEBUG')
+ expect(config[:logging][:log_file]).to eq('debug.log')
+ end
+ end
+ end
+
+ describe 'environment variable overrides' do
+ let(:config_file) { File.join(file_fixtures_path, 'config_files', 'msfmcpd', 'valid_messagepack.yaml') }
+ let(:env_vars) do
+ %w[
+ MSF_API_TYPE MSF_API_HOST MSF_API_PORT MSF_API_SSL MSF_API_ENDPOINT
+ MSF_API_USER MSF_API_PASSWORD MSF_API_TOKEN MSF_AUTO_START_RPC
+ MSF_MCP_TRANSPORT MSF_MCP_HOST MSF_MCP_PORT
+ ]
+ end
+
+ before do
+ env_vars.each { |var| ENV.delete(var) }
+ end
+
+ after do
+ env_vars.each { |var| ENV.delete(var) }
+ end
+
+ context 'MSF API configuration overrides' do
+ context 'when MSF_API_TYPE is set' do
+ before { ENV['MSF_API_TYPE'] = 'json-rpc' }
+
+ it 'overrides the type value' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:type]).to eq('json-rpc')
+ end
+ end
+
+ context 'when MSF_API_HOST is set' do
+ before { ENV['MSF_API_HOST'] = 'override.example.com' }
+
+ it 'overrides the host value' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:host]).to eq('override.example.com')
+ end
+ end
+
+ context 'when MSF_API_PORT is set' do
+ before { ENV['MSF_API_PORT'] = '9999' }
+
+ it 'overrides the port value as integer' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:port]).to eq(9999)
+ end
+ end
+
+ context 'when MSF_API_SSL is set' do
+ let(:ssl_false_config) do
+ { msf_api: { type: 'messagepack', host: 'localhost', ssl: false } }
+ end
+
+ context "to '0'" do
+ before { ENV['MSF_API_SSL'] = '0' }
+
+ it 'overrides SSL to false' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:ssl]).to be false
+ end
+ end
+
+ context "to 'false'" do
+ before { ENV['MSF_API_SSL'] = 'false' }
+
+ it 'overrides SSL to false' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:ssl]).to be false
+ end
+ end
+
+ context "to '1'" do
+ before { ENV['MSF_API_SSL'] = '1' }
+
+ it 'overrides SSL to true' do
+ config = described_class.load_from_hash(ssl_false_config)
+ expect(config[:msf_api][:ssl]).to be true
+ end
+ end
+
+ context "to 'true'" do
+ before { ENV['MSF_API_SSL'] = 'true' }
+
+ it 'overrides SSL to true' do
+ config = described_class.load_from_hash(ssl_false_config)
+ expect(config[:msf_api][:ssl]).to be true
+ end
+ end
+
+ context "to 'yes'" do
+ before { ENV['MSF_API_SSL'] = 'yes' }
+
+ it 'overrides SSL to true' do
+ config = described_class.load_from_hash(ssl_false_config)
+ expect(config[:msf_api][:ssl]).to be true
+ end
+ end
+
+ context "to empty string" do
+ before { ENV['MSF_API_SSL'] = '' }
+
+ it 'does not override SSL' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:ssl]).to be true
+ end
+ end
+ end
+
+ context 'when MSF_API_ENDPOINT is set' do
+ before { ENV['MSF_API_ENDPOINT'] = '/custom/api/v2/' }
+
+ it 'overrides the endpoint value' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:endpoint]).to eq('/custom/api/v2/')
+ end
+ end
+
+ context 'when MSF_API_USER is set' do
+ before { ENV['MSF_API_USER'] = 'env_user' }
+
+ it 'overrides the user value' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:user]).to eq('env_user')
+ end
+ end
+
+ context 'when MSF_API_PASSWORD is set' do
+ before { ENV['MSF_API_PASSWORD'] = 'env_password' }
+
+ it 'overrides the password value' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:password]).to eq('env_password')
+ end
+ end
+
+ context 'when MSF_API_TOKEN is set' do
+ before { ENV['MSF_API_TOKEN'] = 'env_token_123' }
+
+ it 'overrides the token value' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:token]).to eq('env_token_123')
+ end
+ end
+
+ context 'when MSF_AUTO_START_RPC is set' do
+ context "to 'false'" do
+ before { ENV['MSF_AUTO_START_RPC'] = 'false' }
+
+ it 'overrides auto_start_rpc to false' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:auto_start_rpc]).to be false
+ end
+ end
+
+ context "to '0'" do
+ before { ENV['MSF_AUTO_START_RPC'] = '0' }
+
+ it 'overrides auto_start_rpc to false' do
+ config = described_class.load(config_file)
+ expect(config[:msf_api][:auto_start_rpc]).to be false
+ end
+ end
+
+ context "to 'true'" do
+ before { ENV['MSF_AUTO_START_RPC'] = 'true' }
+
+ it 'overrides auto_start_rpc to true' do
+ config = described_class.load_from_hash({ msf_api: { auto_start_rpc: false } })
+ expect(config[:msf_api][:auto_start_rpc]).to be true
+ end
+ end
+
+ context "to '1'" do
+ before { ENV['MSF_AUTO_START_RPC'] = '1' }
+
+ it 'overrides auto_start_rpc to true' do
+ config = described_class.load_from_hash({ msf_api: { auto_start_rpc: false } })
+ expect(config[:msf_api][:auto_start_rpc]).to be true
+ end
+ end
+
+ context "to 'yes'" do
+ before { ENV['MSF_AUTO_START_RPC'] = 'yes' }
+
+ it 'overrides auto_start_rpc to true' do
+ config = described_class.load_from_hash({ msf_api: { auto_start_rpc: false } })
+ expect(config[:msf_api][:auto_start_rpc]).to be true
+ end
+ end
+ end
+ end
+
+ context 'MCP configuration overrides' do
+ context 'when MSF_MCP_TRANSPORT is set' do
+ before { ENV['MSF_MCP_TRANSPORT'] = 'http' }
+
+ it 'overrides the transport value' do
+ config = described_class.load(config_file)
+ expect(config[:mcp][:transport]).to eq('http')
+ end
+ end
+
+ context 'when MSF_MCP_HOST is set' do
+ before { ENV['MSF_MCP_HOST'] = '0.0.0.0' }
+
+ it 'overrides the MCP host value' do
+ config = described_class.load(config_file)
+ expect(config[:mcp][:host]).to eq('0.0.0.0')
+ end
+ end
+
+ context 'when MSF_MCP_PORT is set' do
+ before { ENV['MSF_MCP_PORT'] = '8080' }
+
+ it 'overrides the MCP port value as integer' do
+ config = described_class.load(config_file)
+ expect(config[:mcp][:port]).to eq(8080)
+ end
+ end
+ end
+
+ context 'with multiple ENV vars set' do
+ before do
+ ENV['MSF_API_TYPE'] = 'json-rpc'
+ ENV['MSF_API_HOST'] = 'multi.example.com'
+ ENV['MSF_API_PORT'] = '7777'
+ ENV['MSF_API_USER'] = 'multi_user'
+ ENV['MSF_API_PASSWORD'] = 'multi_pass'
+ ENV['MSF_API_TOKEN'] = 'multi_token'
+ ENV['MSF_MCP_TRANSPORT'] = 'http'
+ ENV['MSF_MCP_HOST'] = '127.0.0.1'
+ ENV['MSF_MCP_PORT'] = '3000'
+ end
+
+ it 'overrides all specified values simultaneously' do
+ config = described_class.load(config_file)
+
+ expect(config[:msf_api][:type]).to eq('json-rpc')
+ expect(config[:msf_api][:host]).to eq('multi.example.com')
+ expect(config[:msf_api][:port]).to eq(7777)
+ expect(config[:msf_api][:user]).to eq('multi_user')
+ expect(config[:msf_api][:password]).to eq('multi_pass')
+ expect(config[:msf_api][:token]).to eq('multi_token')
+ expect(config[:mcp][:transport]).to eq('http')
+ expect(config[:mcp][:host]).to eq('127.0.0.1')
+ expect(config[:mcp][:port]).to eq(3000)
+ end
+ end
+
+ context 'with partial ENV overrides' do
+ before do
+ ENV['MSF_API_HOST'] = 'partial.example.com'
+ ENV['MSF_MCP_TRANSPORT'] = 'http'
+ end
+
+ it 'overrides only specified values while keeping others' do
+ config = described_class.load(config_file)
+
+ expect(config[:msf_api][:host]).to eq('partial.example.com')
+ expect(config[:mcp][:transport]).to eq('http')
+ # Other values should remain from file or defaults
+ expect(config[:msf_api][:port]).to be_a(Integer)
+ expect(config[:msf_api][:endpoint]).to eq('/api/')
+ end
+ end
+
+ context 'when ENV vars are empty strings' do
+ before do
+ ENV['MSF_API_HOST'] = ''
+ ENV['MSF_API_PORT'] = ''
+ end
+
+ it 'overrides with empty strings (current behavior)' do
+ config = described_class.load(config_file)
+
+ # Current implementation: empty strings DO override values
+ # This tests the actual behavior, though it might not be ideal
+ expect(config[:msf_api][:host]).to eq('')
+ expect(config[:msf_api][:port]).to eq(0) # Empty string converts to 0
+ end
+ end
+
+ context 'using load_from_hash with ENV overrides' do
+ let(:config_hash) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'hash.example.com',
+ port: 5555,
+ user: 'hash_user',
+ password: 'hash_pass'
+ },
+ mcp: {
+ transport: 'stdio'
+ }
+ }
+ end
+
+ before do
+ ENV['MSF_API_TYPE'] = 'json-rpc'
+ ENV['MSF_API_HOST'] = 'env.example.com'
+ ENV['MSF_API_PORT'] = '6666'
+ ENV['MSF_MCP_TRANSPORT'] = 'http'
+ end
+
+ it 'applies ENV overrides on top of hash configuration' do
+ config = described_class.load_from_hash(config_hash)
+
+ expect(config[:msf_api][:type]).to eq('json-rpc')
+ expect(config[:msf_api][:host]).to eq('env.example.com')
+ expect(config[:msf_api][:port]).to eq(6666)
+ expect(config[:msf_api][:user]).to eq('hash_user') # Not overridden
+ expect(config[:mcp][:transport]).to eq('http')
+ end
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/config/validator_spec.rb b/spec/lib/msf/core/mcp/config/validator_spec.rb
new file mode 100644
index 0000000000000..4621f8c977f2f
--- /dev/null
+++ b/spec/lib/msf/core/mcp/config/validator_spec.rb
@@ -0,0 +1,1030 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Config::Validator do
+ describe '.validate!' do
+ context 'with valid messagepack configuration' do
+ let(:valid_config) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 55553,
+ user: 'msf',
+ password: 'password'
+ }
+ }
+ end
+
+ it 'returns true for valid configuration' do
+ expect(described_class.validate!(valid_config)).to be true
+ end
+
+ it 'does not raise error' do
+ expect { described_class.validate!(valid_config) }.not_to raise_error
+ end
+ end
+
+ context 'with valid json-rpc configuration' do
+ let(:valid_config) do
+ {
+ msf_api: {
+ type: 'json-rpc',
+ host: 'localhost',
+ port: 8081,
+ token: 'secret_token_123'
+ }
+ }
+ end
+
+ it 'returns true for valid configuration' do
+ expect(described_class.validate!(valid_config)).to be true
+ end
+ end
+
+ context 'with missing optional fields' do
+ it 'does not raise error for missing msf_api.type (has default)' do
+ config = { msf_api: { host: 'localhost', user: 'msf', password: 'pass' } }
+
+ expect {
+ described_class.validate!(config)
+ }.not_to raise_error
+ end
+
+ it 'does not raise error for missing msf_api.host (has default)' do
+ config = { msf_api: { type: 'messagepack', user: 'msf', password: 'pass' } }
+
+ expect {
+ described_class.validate!(config)
+ }.not_to raise_error
+ end
+
+ it 'raises ValidationError for empty msf_api.type' do
+ config = {
+ msf_api: {
+ type: '',
+ host: 'localhost',
+ user: 'msf',
+ password: 'pass'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.type']).to eq("must be one of the valid API types: messagepack, json-rpc")
+ end
+ end
+
+ it 'raises ValidationError for whitespace-only msf_api.host' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: ' ',
+ user: 'msf',
+ password: 'pass'
+ }
+ }
+
+ # Whitespace host is not validated as "required", loader will apply default
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.host']).to eq("must be a non-empty string")
+ end
+ end
+
+ it 'does not raise error for missing type and host (have defaults)' do
+ config = { msf_api: { user: 'msf', password: 'pass' } }
+
+ expect {
+ described_class.validate!(config)
+ }.not_to raise_error
+ end
+ end
+
+ context 'with invalid enum values' do
+ it 'raises ValidationError for invalid msf_api.type' do
+ config = {
+ msf_api: {
+ type: 'soap',
+ host: 'localhost'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.type']).to eq("must be one of the valid API types: messagepack, json-rpc")
+ end
+ end
+
+ it 'raises ValidationError for invalid mcp_transport' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'password'
+ },
+ mcp: {
+ transport: 'websocket'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'mcp.transport']).to eq("must be one of the valid transport: stdio, http")
+ end
+ end
+
+ it 'allows valid mcp_transport http' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'password'
+ },
+ mcp: {
+ transport: 'http'
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'allows valid mcp_transport stdio' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'password'
+ },
+ mcp: {
+ transport: 'stdio'
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+ end
+
+ context 'with port validation' do
+ it 'accepts port 8080' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 8080,
+ user: 'msf',
+ password: 'password'
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'accepts port 1' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 1,
+ user: 'msf',
+ password: 'password'
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'accepts port 65535' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 65535,
+ user: 'msf',
+ password: 'password'
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'rejects port 0' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 0,
+ user: 'msf',
+ password: 'password'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.port']).to eq('must be between 1 and 65535')
+ end
+ end
+
+ it 'rejects negative port' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: -1,
+ user: 'msf',
+ password: 'password'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.port']).to eq('must be between 1 and 65535')
+ end
+ end
+
+ it 'rejects port 65536' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 65536,
+ user: 'msf',
+ password: 'password'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.port']).to eq('must be between 1 and 65535')
+ end
+ end
+ end
+
+ context 'with auto_start_rpc validation' do
+ it 'accepts auto_start_rpc set to true' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'password',
+ auto_start_rpc: true
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'accepts auto_start_rpc set to false' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'password',
+ auto_start_rpc: false
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'raises ValidationError for non-boolean auto_start_rpc' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'password',
+ auto_start_rpc: 'yes'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.auto_start_rpc']).to eq('must be boolean (true or false)')
+ end
+ end
+
+ it 'raises ValidationError for integer auto_start_rpc' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'password',
+ auto_start_rpc: 1
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.auto_start_rpc']).to eq('must be boolean (true or false)')
+ end
+ end
+
+ it 'does not raise error when auto_start_rpc is not present' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'password'
+ }
+ }
+
+ expect { described_class.validate!(config) }.not_to raise_error
+ end
+ end
+
+ context 'with conditional messagepack authentication requirements' do
+ it 'raises ValidationError for missing msf_api.user when auto-start cannot generate credentials' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ password: 'password',
+ auto_start_rpc: false
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.user']).to eq('is required for MessagePack authentication. Use --user option or MSF_API_USER environment variable')
+ end
+ end
+
+ it 'raises ValidationError for empty msf_api.user' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: '',
+ password: 'password',
+ auto_start_rpc: false
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.user']).to eq('is required for MessagePack authentication. Use --user option or MSF_API_USER environment variable')
+ end
+ end
+
+ it 'raises ValidationError for missing msf_api.password when auto-start cannot generate credentials' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ auto_start_rpc: false
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.password']).to eq('is required for MessagePack authentication. Use --password option or MSF_API_PASSWORD environment variable')
+ end
+ end
+
+ it 'raises ValidationError for whitespace-only msf_api.password' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: ' ',
+ auto_start_rpc: false
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.password']).to eq('is required for MessagePack authentication. Use --password option or MSF_API_PASSWORD environment variable')
+ end
+ end
+
+ it 'raises ValidationError for both missing user and password when auto-start is disabled' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ auto_start_rpc: false
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.user']).to eq('is required for MessagePack authentication. Use --user option or MSF_API_USER environment variable')
+ expect(error.errors[:'msf_api.password']).to eq('is required for MessagePack authentication. Use --password option or MSF_API_PASSWORD environment variable')
+ end
+ end
+
+ it 'raises ValidationError for both missing user and password on remote host' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: '192.0.2.1',
+ auto_start_rpc: true
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.user']).to be_a(String)
+ expect(error.errors[:'msf_api.password']).to be_a(String)
+ end
+ end
+
+ it 'raises ValidationError for only user provided (partial credentials)' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ auto_start_rpc: true
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.password']).to be_a(String)
+ end
+ end
+
+ it 'raises ValidationError for only password provided (partial credentials)' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ password: 'pass',
+ auto_start_rpc: true
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.user']).to be_a(String)
+ end
+ end
+ end
+
+ context 'with optional credentials when auto-start can generate them' do
+ it 'allows missing credentials on localhost with auto_start_rpc enabled' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ auto_start_rpc: true
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'allows missing credentials on 127.0.0.1 with auto_start_rpc enabled' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: '127.0.0.1',
+ auto_start_rpc: true
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'allows missing credentials on ::1 with auto_start_rpc enabled' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: '::1',
+ auto_start_rpc: true
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'allows missing credentials when auto_start_rpc key is absent (defaults to true)' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost'
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'does not allow missing credentials on remote host even with auto_start_rpc true' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: '192.0.2.1',
+ auto_start_rpc: true
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError)
+ end
+
+ it 'does not allow missing credentials when auto_start_rpc is false' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ auto_start_rpc: false
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError)
+ end
+ end
+
+ context 'with conditional json-rpc authentication requirements' do
+ it 'raises ValidationError for missing msf_api.token' do
+ config = {
+ msf_api: {
+ type: 'json-rpc',
+ host: 'localhost'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.token']).to eq('is required for JSON-RPC authentication')
+ end
+ end
+
+ it 'raises ValidationError for empty msf_api.token' do
+ config = {
+ msf_api: {
+ type: 'json-rpc',
+ host: 'localhost',
+ token: ''
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.token']).to eq('is required for JSON-RPC authentication')
+ end
+ end
+
+ it 'raises ValidationError for whitespace-only msf_api.token' do
+ config = {
+ msf_api: {
+ type: 'json-rpc',
+ host: 'localhost',
+ token: ' '
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'msf_api.token']).to eq('is required for JSON-RPC authentication')
+ end
+ end
+
+ it 'accepts valid token' do
+ config = {
+ msf_api: {
+ type: 'json-rpc',
+ host: 'localhost',
+ token: 'valid_token_123'
+ }
+ }
+
+ expect(described_class.validate!(config)).to be true
+ end
+ end
+
+ context 'with multiple validation errors' do
+ it 'collects all validation errors' do
+ config = {
+ msf_api: {
+ type: 'invalid_type',
+ port: 0,
+ user: 'msf',
+ password: 'pass'
+ },
+ mcp: {
+ transport: 'invalid'
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors.keys).to include(:'msf_api.type', :'msf_api.port', :'mcp.transport')
+ expect(error.errors.size).to be >= 3
+ end
+ end
+
+ it 'includes all errors in message' do
+ config = {
+ msf_api: {
+ type: 'messagepack',
+ port: 70000,
+ user: '',
+ password: ''
+ }
+ }
+
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.message).to include('msf_api.port')
+ expect(error.message).to include('msf_api.user')
+ expect(error.message).to include('msf_api.password')
+ end
+ end
+ end
+ context 'with rate_limit validation' do
+ let(:base_config) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'pass'
+ }
+ }
+ end
+
+ it 'accepts valid rate_limit configuration' do
+ config = base_config.merge(rate_limit: { enabled: true, requests_per_minute: 60, burst_size: 10 })
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'accepts rate_limit with only enabled' do
+ config = base_config.merge(rate_limit: { enabled: false })
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'accepts rate_limit with no keys (all optional)' do
+ config = base_config.merge(rate_limit: {})
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'does not validate rate_limit when section is absent' do
+ expect(described_class.validate!(base_config)).to be true
+ end
+
+ it 'raises ValidationError when rate_limit is not a Hash' do
+ config = base_config.merge(rate_limit: 'yes')
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:rate_limit]).to eq('must be a configuration hash')
+ end
+ end
+
+ it 'raises ValidationError for non-boolean enabled' do
+ config = base_config.merge(rate_limit: { enabled: 'yes' })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'rate_limit.enabled']).to eq('must be boolean (true or false)')
+ end
+ end
+
+ it 'raises ValidationError for requests_per_minute of 0' do
+ config = base_config.merge(rate_limit: { requests_per_minute: 0 })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'rate_limit.requests_per_minute']).to eq('must be an integer >= 1')
+ end
+ end
+
+ it 'raises ValidationError for negative requests_per_minute' do
+ config = base_config.merge(rate_limit: { requests_per_minute: -5 })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'rate_limit.requests_per_minute']).to eq('must be an integer >= 1')
+ end
+ end
+
+ it 'raises ValidationError for non-integer requests_per_minute' do
+ config = base_config.merge(rate_limit: { requests_per_minute: 'fast' })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'rate_limit.requests_per_minute']).to eq('must be an integer >= 1')
+ end
+ end
+
+ it 'raises ValidationError for float requests_per_minute' do
+ config = base_config.merge(rate_limit: { requests_per_minute: 1.5 })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'rate_limit.requests_per_minute']).to eq('must be an integer >= 1')
+ end
+ end
+
+ it 'accepts requests_per_minute of 1' do
+ config = base_config.merge(rate_limit: { requests_per_minute: 1 })
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'raises ValidationError for burst_size of 0' do
+ config = base_config.merge(rate_limit: { burst_size: 0 })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'rate_limit.burst_size']).to eq('must be an integer >= 1')
+ end
+ end
+
+ it 'raises ValidationError for negative burst_size' do
+ config = base_config.merge(rate_limit: { burst_size: -1 })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'rate_limit.burst_size']).to eq('must be an integer >= 1')
+ end
+ end
+
+ it 'raises ValidationError for non-integer burst_size' do
+ config = base_config.merge(rate_limit: { burst_size: 'large' })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'rate_limit.burst_size']).to eq('must be an integer >= 1')
+ end
+ end
+
+ it 'accepts burst_size of 1' do
+ config = base_config.merge(rate_limit: { burst_size: 1 })
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'collects multiple rate_limit errors at once' do
+ config = base_config.merge(rate_limit: { enabled: 'yes', requests_per_minute: 0, burst_size: -1 })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors.keys).to include(:'rate_limit.enabled', :'rate_limit.requests_per_minute', :'rate_limit.burst_size')
+ end
+ end
+ end
+ context 'with mcp section type validation' do
+ let(:base_config) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'pass'
+ }
+ }
+ end
+
+ it 'raises ValidationError when mcp is not a Hash' do
+ config = base_config.merge(mcp: 'stdio')
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:mcp]).to eq('must be a configuration hash')
+ end
+ end
+
+ it 'raises ValidationError when mcp is an integer' do
+ config = base_config.merge(mcp: 42)
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:mcp]).to eq('must be a configuration hash')
+ end
+ end
+
+ it 'does not raise when mcp is absent' do
+ expect(described_class.validate!(base_config)).to be true
+ end
+ end
+
+ context 'with logging validation' do
+ let(:base_config) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ user: 'msf',
+ password: 'pass'
+ }
+ }
+ end
+
+ it 'accepts valid logging configuration' do
+ config = base_config.merge(logging: { enabled: true, level: 'INFO', log_file: 'msfmcp.log' })
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'accepts logging with no keys' do
+ config = base_config.merge(logging: {})
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'does not validate logging when section is absent' do
+ expect(described_class.validate!(base_config)).to be true
+ end
+
+ it 'raises ValidationError when logging is not a Hash' do
+ config = base_config.merge(logging: true)
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:logging]).to eq('must be a configuration hash')
+ end
+ end
+
+ it 'raises ValidationError for non-boolean logging.enabled' do
+ config = base_config.merge(logging: { enabled: 'yes' })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'logging.enabled']).to eq('must be boolean (true or false)')
+ end
+ end
+
+ it 'raises ValidationError for invalid logging.level' do
+ config = base_config.merge(logging: { level: 'VERBOSE' })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'logging.level']).to include('must be one of')
+ end
+ end
+
+ it 'accepts all valid log levels' do
+ %w[DEBUG INFO WARN ERROR].each do |level|
+ config = base_config.merge(logging: { level: level })
+ expect(described_class.validate!(config)).to be true
+ end
+ end
+
+ it 'accepts case-insensitive log levels' do
+ config = base_config.merge(logging: { level: 'info' })
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'raises ValidationError for empty logging.log_file' do
+ config = base_config.merge(logging: { log_file: '' })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'logging.log_file']).to eq('must be a non-empty string')
+ end
+ end
+
+ it 'raises ValidationError for whitespace-only logging.log_file' do
+ config = base_config.merge(logging: { log_file: ' ' })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'logging.log_file']).to eq('must be a non-empty string')
+ end
+ end
+
+ it 'accepts sanitize set to true' do
+ config = base_config.merge(logging: { sanitize: true })
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'accepts sanitize set to false' do
+ config = base_config.merge(logging: { sanitize: false })
+ expect(described_class.validate!(config)).to be true
+ end
+
+ it 'raises ValidationError for non-boolean logging.sanitize' do
+ config = base_config.merge(logging: { sanitize: 'yes' })
+ expect {
+ described_class.validate!(config)
+ }.to raise_error(Msf::MCP::Config::ValidationError) do |error|
+ expect(error.errors[:'logging.sanitize']).to eq('must be boolean (true or false)')
+ end
+ end
+
+ it 'does not validate sanitize when key is absent' do
+ config = base_config.merge(logging: { enabled: true, level: 'INFO' })
+ expect(described_class.validate!(config)).to be true
+ end
+ end
+ end
+
+ describe '#credentials_can_be_generated?' do
+ subject(:validator) { described_class.new }
+
+ it 'returns true for localhost with auto_start_rpc true' do
+ config = { msf_api: { host: 'localhost', auto_start_rpc: true } }
+ expect(validator.send(:credentials_can_be_generated?, config)).to be true
+ end
+
+ it 'returns true for 127.0.0.1 with auto_start_rpc true' do
+ config = { msf_api: { host: '127.0.0.1', auto_start_rpc: true } }
+ expect(validator.send(:credentials_can_be_generated?, config)).to be true
+ end
+
+ it 'returns true for ::1 with auto_start_rpc true' do
+ config = { msf_api: { host: '::1', auto_start_rpc: true } }
+ expect(validator.send(:credentials_can_be_generated?, config)).to be true
+ end
+
+ it 'returns true when auto_start_rpc key is absent (not explicitly false)' do
+ config = { msf_api: { host: 'localhost' } }
+ expect(validator.send(:credentials_can_be_generated?, config)).to be true
+ end
+
+ it 'returns false when auto_start_rpc is false' do
+ config = { msf_api: { host: 'localhost', auto_start_rpc: false } }
+ expect(validator.send(:credentials_can_be_generated?, config)).to be false
+ end
+
+ it 'returns false for remote host' do
+ config = { msf_api: { host: '192.0.2.1', auto_start_rpc: true } }
+ expect(validator.send(:credentials_can_be_generated?, config)).to be false
+ end
+
+ it 'returns false for remote hostname' do
+ config = { msf_api: { host: 'remote.example.com', auto_start_rpc: true } }
+ expect(validator.send(:credentials_can_be_generated?, config)).to be false
+ end
+
+ it 'returns false when host is nil' do
+ config = { msf_api: { auto_start_rpc: true } }
+ expect(validator.send(:credentials_can_be_generated?, config)).to be false
+ end
+ end
+
+ describe Msf::MCP::Config::ValidationError do
+ describe '#message' do
+ it 'has default message for empty errors' do
+ error = described_class.new({})
+ expect(error.message).to eq('Configuration validation failed')
+ end
+
+ it 'includes field names and error descriptions' do
+ errors = {
+ :'msf_api.host' => 'is required',
+ :'msf_api.port' => 'must be between 1 and 65535'
+ }
+ error = described_class.new(errors)
+
+ expect(error.message).to include('msf_api.host is required')
+ expect(error.message).to include('msf_api.port must be between 1 and 65535')
+ end
+
+ it 'formats multiple errors with bullets' do
+ errors = {
+ :'msf_api.type' => 'is required',
+ :'msf_api.host' => 'is required'
+ }
+ error = described_class.new(errors)
+
+ expect(error.message).to include('Configuration validation failed:')
+ expect(error.message).to include(' - ')
+ end
+ end
+
+ describe '#errors' do
+ it 'provides access to errors hash' do
+ errors = { :'msf_api.host' => 'is required' }
+ error = described_class.new(errors)
+
+ expect(error.errors).to eq(errors)
+ end
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/errors_spec.rb b/spec/lib/msf/core/mcp/errors_spec.rb
new file mode 100644
index 0000000000000..b9a8aef66644b
--- /dev/null
+++ b/spec/lib/msf/core/mcp/errors_spec.rb
@@ -0,0 +1,120 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Error do
+ describe 'inheritance' do
+ it 'inherits from StandardError' do
+ expect(described_class).to be < StandardError
+ end
+
+ it 'can be rescued as StandardError' do
+ expect do
+ raise described_class, 'test'
+ end.to raise_error(StandardError)
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Config::ConfigurationError do
+ describe 'inheritance' do
+ it 'inherits from Msf::MCP::Error' do
+ expect(described_class).to be < Msf::MCP::Error
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Config::ValidationError do
+ describe 'inheritance' do
+ it 'inherits from Msf::MCP::Error' do
+ expect(described_class).to be < Msf::MCP::Error
+ end
+ end
+
+ describe '#initialize' do
+ it 'stores validation errors' do
+ errors = { 'msf_api.type' => 'must be one of the valid API types: messagepack, json-rpc' }
+ exception = described_class.new(errors)
+ expect(exception.errors).to eq(errors)
+ end
+
+ it 'returns a generic message if no errors have been stored' do
+ exception = described_class.new
+ expect(exception.message).to eq("Configuration validation failed")
+ end
+
+ it 'formats error messages correctly' do
+ errors = {
+ 'msf_api.type' => 'must be one of the valid API types: messagepack, json-rpc',
+ 'msf_api.host' => 'must be a non-empty string'
+ }
+ exception = described_class.new(errors)
+ expected_message = <<~MSG.chomp
+ Configuration validation failed:
+ - msf_api.type must be one of the valid API types: messagepack, json-rpc
+ - msf_api.host must be a non-empty string
+ MSG
+ expect(exception.message).to eq(expected_message)
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Security::ValidationError do
+ describe 'inheritance' do
+ it 'inherits from Msf::MCP::Error' do
+ expect(described_class).to be < Msf::MCP::Error
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Security::RateLimitExceededError do
+ describe 'inheritance' do
+ it 'inherits from Msf::MCP::Error' do
+ expect(described_class).to be < Msf::MCP::Error
+ end
+ end
+
+ describe '#initialize' do
+ it 'stores retry_after value' do
+ exception = described_class.new(32)
+ expect(exception.retry_after).to eq(32)
+ end
+
+ it 'formats message correctly' do
+ exception = described_class.new(32)
+ expect(exception.message).to eq("Rate limit exceeded. Retry after 32 seconds.")
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Metasploit::AuthenticationError do
+ describe 'inheritance' do
+ it 'inherits from Msf::MCP::Error' do
+ expect(described_class).to be < Msf::MCP::Error
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Metasploit::ConnectionError do
+ describe 'inheritance' do
+ it 'inherits from Msf::MCP::Error' do
+ expect(described_class).to be < Msf::MCP::Error
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Metasploit::APIError do
+ describe 'inheritance' do
+ it 'inherits from Msf::MCP::Error' do
+ expect(described_class).to be < Msf::MCP::Error
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Metasploit::RpcStartupError do
+ describe 'inheritance' do
+ it 'inherits from Msf::MCP::Error' do
+ expect(described_class).to be < Msf::MCP::Error
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/logging/log_constants_spec.rb b/spec/lib/msf/core/mcp/logging/log_constants_spec.rb
new file mode 100644
index 0000000000000..a0e163cafa7f2
--- /dev/null
+++ b/spec/lib/msf/core/mcp/logging/log_constants_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'tempfile'
+require 'json'
+
+RSpec.describe 'Msf::MCP log constants' do
+ describe 'LOG_SOURCE' do
+ it 'is set to mcp' do
+ expect(Msf::MCP::LOG_SOURCE).to eq('mcp')
+ end
+ end
+
+ describe 'LOG_LEVEL constants' do
+ it 'maps LOG_DEBUG to LEV_3' do
+ expect(Msf::MCP::LOG_DEBUG).to eq(Rex::Logging::LEV_3)
+ end
+
+ it 'maps LOG_INFO to LEV_2' do
+ expect(Msf::MCP::LOG_INFO).to eq(Rex::Logging::LEV_2)
+ end
+
+ it 'maps LOG_WARN to LEV_1' do
+ expect(Msf::MCP::LOG_WARN).to eq(Rex::Logging::LEV_1)
+ end
+
+ it 'maps LOG_ERROR to LEV_0' do
+ expect(Msf::MCP::LOG_ERROR).to eq(Rex::Logging::LEV_0)
+ end
+ end
+
+ describe 'explicit source and level usage' do
+ let(:log_file) { Tempfile.new(['log_constants_test', '.log']).tap(&:close).path }
+ let(:log_source) { Msf::MCP::LOG_SOURCE }
+
+ before do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), Rex::Logging::LEV_3)
+ end
+
+ after do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ File.delete(log_file) if File.exist?(log_file)
+ end
+
+ def last_log_entry
+ JSON.parse(File.read(log_file).strip.split("\n").last)
+ end
+
+ it 'routes messages to the mcp source when passed explicitly' do
+ ilog('info message', log_source, Msf::MCP::LOG_INFO)
+ expect(last_log_entry['message']).to include('info message')
+ expect(last_log_entry['severity']).to eq('INFO')
+ end
+
+ it 'does not affect the default core source' do
+ ilog('core message')
+ expect(File.read(log_file)).to be_empty
+ end
+
+ it 'filters messages below the registered threshold' do
+ deregister_log_source(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), Rex::Logging::LEV_1)
+
+ dlog('should be filtered', log_source, Msf::MCP::LOG_DEBUG)
+ expect(File.read(log_file)).to be_empty
+
+ wlog('should appear', log_source, Msf::MCP::LOG_WARN)
+ expect(File.read(log_file)).not_to be_empty
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/logging/sinks/json_flatfile_spec.rb b/spec/lib/msf/core/mcp/logging/sinks/json_flatfile_spec.rb
new file mode 100644
index 0000000000000..62f9720af39f1
--- /dev/null
+++ b/spec/lib/msf/core/mcp/logging/sinks/json_flatfile_spec.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'tempfile'
+require 'json'
+
+RSpec.describe Msf::MCP::Logging::Sinks::JsonFlatfile do
+ let(:log_path) { Tempfile.new(['json_flatfile_test', '.log']).tap(&:close).path }
+ let(:sink) { described_class.new(log_path) }
+
+ after do
+ sink.cleanup rescue nil
+ File.delete(log_path) if File.exist?(log_path)
+ end
+
+ describe '#initialize' do
+ it 'creates the log file' do
+ described_class.new(log_path)
+ expect(File.exist?(log_path)).to be true
+ end
+
+ it 'opens the file in append mode' do
+ # Write some content first
+ File.write(log_path, "existing\n")
+ new_sink = described_class.new(log_path)
+ new_sink.log(:info, 'mcp', 0, 'appended')
+ new_sink.cleanup
+
+ content = File.read(log_path)
+ expect(content).to start_with("existing\n")
+ expect(content).to include('appended')
+ end
+
+ it 'inherits from JsonStream' do
+ expect(described_class.superclass).to eq(Msf::MCP::Logging::Sinks::JsonStream)
+ end
+ end
+
+ describe '#log' do
+ it 'writes a JSON line to the file' do
+ sink.log(:info, 'mcp', 0, 'test message')
+ content = File.read(log_path)
+ expect(content).not_to be_empty
+
+ entry = JSON.parse(content.strip)
+ expect(entry).to be_a(Hash)
+ end
+
+ it 'includes timestamp, severity, level, source, and message' do
+ sink.log(:error, 'mcp', 2, 'something broke')
+ entry = JSON.parse(File.read(log_path).strip)
+
+ expect(entry['timestamp']).not_to be_nil
+ expect(entry['severity']).to eq('ERROR')
+ expect(entry['level']).to eq('2')
+ expect(entry['source']).to eq('mcp')
+ expect(entry['message']).to include('something broke')
+ end
+
+ it 'writes one JSON object per line' do
+ sink.log(:info, 'mcp', 0, 'first')
+ sink.log(:info, 'mcp', 0, 'second')
+
+ lines = File.read(log_path).strip.split("\n")
+ expect(lines.length).to eq(2)
+ expect(JSON.parse(lines[0])['message']).to include('first')
+ expect(JSON.parse(lines[1])['message']).to include('second')
+ end
+
+ context 'with a Hash message' do
+ it 'extracts :message from the hash' do
+ sink.log(:info, 'mcp', 0, { message: 'structured log' })
+ entry = JSON.parse(File.read(log_path).strip)
+
+ expect(entry['message']).to eq('structured log')
+ end
+
+ it 'includes :context when present' do
+ sink.log(:info, 'mcp', 0, { message: 'with context', context: { tool: 'search' } })
+ entry = JSON.parse(File.read(log_path).strip)
+
+ expect(entry['context']).to be_a(Hash)
+ expect(entry['context']['tool']).to eq('search')
+ end
+
+ it 'omits :context when empty' do
+ sink.log(:info, 'mcp', 0, { message: 'no context', context: {} })
+ entry = JSON.parse(File.read(log_path).strip)
+
+ expect(entry).not_to have_key('context')
+ end
+
+ it 'formats Exception in :exception as structured hash' do
+ error = StandardError.new('test error')
+ error.set_backtrace(['line1', 'line2'])
+
+ sink.log(:error, 'mcp', 0, { message: 'error occurred', exception: error })
+ entry = JSON.parse(File.read(log_path).strip)
+
+ expect(entry['exception']).to be_a(Hash)
+ expect(entry['exception']['class']).to eq('StandardError')
+ expect(entry['exception']['message']).to eq('test error')
+ end
+
+ it 'passes non-Exception :exception values through as-is' do
+ sink.log(:error, 'mcp', 0, { message: 'error', exception: 'just a string' })
+ entry = JSON.parse(File.read(log_path).strip)
+
+ expect(entry['exception']).to eq('just a string')
+ end
+ end
+
+ context 'with a String message' do
+ it 'uses the string directly as message' do
+ sink.log(:info, 'mcp', 0, 'plain string')
+ entry = JSON.parse(File.read(log_path).strip)
+
+ expect(entry['message']).to eq('plain string')
+ expect(entry).not_to have_key('context')
+ expect(entry).not_to have_key('exception')
+ end
+ end
+ end
+
+ describe '#cleanup' do
+ it 'closes the underlying file' do
+ sink.log(:info, 'mcp', 0, 'before cleanup')
+ sink.cleanup
+
+ # Writing after cleanup should raise (file is closed)
+ expect { sink.log(:info, 'mcp', 0, 'after cleanup') }.to raise_error(IOError)
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/logging/sinks/json_stream_spec.rb b/spec/lib/msf/core/mcp/logging/sinks/json_stream_spec.rb
new file mode 100644
index 0000000000000..547502bdf99ef
--- /dev/null
+++ b/spec/lib/msf/core/mcp/logging/sinks/json_stream_spec.rb
@@ -0,0 +1,246 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'stringio'
+require 'json'
+
+RSpec.describe Msf::MCP::Logging::Sinks::JsonStream do
+ let(:stream) { StringIO.new }
+ let(:sink) { described_class.new(stream) }
+ let(:log_source) { Msf::MCP::LOG_SOURCE }
+
+ # Helper: parse the last JSON line written to the stream
+ def last_entry
+ stream.rewind
+ lines = stream.read.strip.split("\n")
+ JSON.parse(lines.last)
+ end
+
+ describe '#initialize' do
+ it 'accepts any IO-like stream' do
+ expect { described_class.new(StringIO.new) }.not_to raise_error
+ end
+
+ it 'includes Rex::Logging::LogSink' do
+ expect(described_class.ancestors).to include(Rex::Logging::LogSink)
+ end
+ end
+
+ describe '#log' do
+ it 'writes JSON to the stream' do
+ sink.log(:info, 'mcp', 0, 'test')
+ expect(last_entry).to be_a(Hash)
+ end
+
+ it 'flushes after each write' do
+ expect(stream).to receive(:flush).once
+ sink.log(:info, 'mcp', 0, 'test')
+ end
+
+ it 'appends a newline after each entry' do
+ sink.log(:info, 'mcp', 0, 'test')
+ stream.rewind
+ expect(stream.read).to end_with("\n")
+ end
+
+ it 'includes a timestamp' do
+ sink.log(:info, 'mcp', 0, 'test')
+ expect(last_entry['timestamp']).not_to be_nil
+ end
+
+ it 'uppercases the severity' do
+ sink.log(:error, 'mcp', 0, 'test')
+ expect(last_entry['severity']).to eq('ERROR')
+ end
+
+ it 'converts level to string' do
+ sink.log(:info, 'mcp', 2, 'test')
+ expect(last_entry['level']).to eq('2')
+ end
+
+ it 'converts source to string' do
+ sink.log(:info, :mcp, 0, 'test')
+ expect(last_entry['source']).to eq('mcp')
+ end
+
+ context 'with a String message' do
+ it 'uses the string as message' do
+ sink.log(:info, 'mcp', 0, 'plain text')
+ expect(last_entry['message']).to eq('plain text')
+ end
+
+ it 'does not include context or exception keys' do
+ sink.log(:info, 'mcp', 0, 'plain text')
+ expect(last_entry).not_to have_key('context')
+ expect(last_entry).not_to have_key('exception')
+ end
+ end
+
+ context 'with a Hash message' do
+ it 'extracts :message from the hash' do
+ sink.log(:info, 'mcp', 0, { message: 'structured' })
+ expect(last_entry['message']).to eq('structured')
+ end
+
+ it 'falls back to hash.to_s when :message is nil' do
+ sink.log(:info, 'mcp', 0, { context: { a: 1 } })
+ # message is the Hash#to_s representation since :message key is absent
+ expect(last_entry['message']).to include('context')
+ end
+
+ it 'does not overwrite message when :message is empty string' do
+ sink.log(:info, 'mcp', 0, { message: '', context: { a: 1 } })
+ # Empty :message is skipped, so message stays as hash.to_s
+ expect(last_entry['message']).to include('context')
+ end
+
+ it 'includes :context when present and non-empty' do
+ sink.log(:info, 'mcp', 0, { message: 'test', context: { tool: 'search' } })
+ expect(last_entry['context']).to eq({ 'tool' => 'search' })
+ end
+
+ it 'omits :context when nil' do
+ sink.log(:info, 'mcp', 0, { message: 'test', context: nil })
+ expect(last_entry).not_to have_key('context')
+ end
+
+ it 'omits :context when empty hash' do
+ sink.log(:info, 'mcp', 0, { message: 'test', context: {} })
+ expect(last_entry).not_to have_key('context')
+ end
+ end
+
+ context 'with an Exception in :exception' do
+ let(:error) do
+ StandardError.new('boom').tap do |e|
+ e.set_backtrace(%w[line1 line2 line3 line4 line5 line6])
+ end
+ end
+
+ it 'formats the exception as a structured hash' do
+ sink.log(:error, 'mcp', 0, { message: 'fail', exception: error })
+ ex = last_entry['exception']
+
+ expect(ex['class']).to eq('StandardError')
+ expect(ex['message']).to eq('boom')
+ end
+
+ it 'includes backtrace at DEBUG log level' do
+ # Register at LEV_3 (DEBUG) to enable backtrace
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_3)
+
+ sink.log(:error, 'mcp', 0, { message: 'fail', exception: error })
+ ex = last_entry['exception']
+
+ expect(ex['backtrace']).to be_an(Array)
+ expect(ex['backtrace'].length).to eq(5) # first(5)
+ expect(ex['backtrace']).not_to include('line6')
+
+ deregister_log_source(log_source)
+ end
+
+ it 'omits backtrace below DEBUG log level' do
+ # Register at LEV_0 — below BACKTRACE_LOG_LEVEL (3)
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_0)
+
+ sink.log(:error, 'mcp', 0, { message: 'fail', exception: error })
+ ex = last_entry['exception']
+
+ expect(ex).not_to have_key('backtrace')
+
+ deregister_log_source(log_source)
+ end
+
+ it 'handles exception with nil backtrace' do
+ no_bt = RuntimeError.new('no trace')
+ # backtrace is nil by default when not raised
+
+ sink.log(:error, 'mcp', 0, { message: 'fail', exception: no_bt })
+ ex = last_entry['exception']
+
+ expect(ex['class']).to eq('RuntimeError')
+ expect(ex['message']).to eq('no trace')
+ end
+
+ it 'passes non-Exception :exception values through' do
+ sink.log(:error, 'mcp', 0, { message: 'fail', exception: 'string error' })
+ expect(last_entry['exception']).to eq('string error')
+ end
+ end
+
+ context 'context summarization at non-DEBUG level' do
+ before do
+ # Register at LEV_0 so debug_log_level? returns false
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_0)
+ end
+
+ after do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ end
+
+ it 'truncates heavy keys (:result, :body, :error)' do
+ long_value = 'x' * 2000
+ sink.log(:info, 'mcp', 0, { message: 'test', context: { result: long_value } })
+ ctx = last_entry['context']
+
+ expect(ctx['result']).to include('truncated')
+ expect(ctx['result'].length).to be < 2000
+ end
+
+ it 'passes through non-heavy scalar keys unchanged' do
+ sink.log(:info, 'mcp', 0, { message: 'test', context: { method: 'tools/call', elapsed_ms: 42 } })
+ ctx = last_entry['context']
+
+ expect(ctx['method']).to eq('tools/call')
+ expect(ctx['elapsed_ms']).to eq(42)
+ end
+
+ it 'truncates heavy keys inside :response sub-hash' do
+ long_result = 'y' * 2000
+ context = { response: { status: 200, result: long_result } }
+ sink.log(:info, 'mcp', 0, { message: 'test', context: context })
+ resp = last_entry['context']['response']
+
+ expect(resp['status']).to eq(200)
+ expect(resp['result']).to include('truncated')
+ end
+
+ it 'does not truncate short values' do
+ sink.log(:info, 'mcp', 0, { message: 'test', context: { result: 'short' } })
+ ctx = last_entry['context']
+
+ expect(ctx['result']).to eq('short')
+ end
+ end
+
+ context 'context at DEBUG level' do
+ before do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_3)
+ end
+
+ after do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ end
+
+ it 'passes context through without summarization' do
+ long_value = 'x' * 2000
+ sink.log(:info, 'mcp', 0, { message: 'test', context: { result: long_value } })
+ ctx = last_entry['context']
+
+ expect(ctx['result']).to eq(long_value)
+ expect(ctx['result']).not_to include('truncated')
+ end
+ end
+ end
+
+ describe '#cleanup' do
+ it 'closes the stream' do
+ sink.cleanup
+ expect(stream).to be_closed
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb b/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb
new file mode 100644
index 0000000000000..5b560a81b5c26
--- /dev/null
+++ b/spec/lib/msf/core/mcp/logging/sinks/sanitizing_spec.rb
@@ -0,0 +1,313 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'stringio'
+require 'json'
+
+RSpec.describe Msf::MCP::Logging::Sinks::Sanitizing do
+ let(:stream) { StringIO.new }
+ let(:inner_sink) { Msf::MCP::Logging::Sinks::JsonStream.new(stream) }
+ let(:sink) { described_class.new(inner_sink) }
+ let(:log_source) { Msf::MCP::LOG_SOURCE }
+
+ # Helper: parse the last JSON log entry from the stream
+ def last_log_entry
+ stream.rewind
+ lines = stream.read.strip.split("\n")
+ JSON.parse(lines.last)
+ end
+
+ describe '#log' do
+ it 'delegates to the inner sink' do
+ sink.log(:info, 'mcp', 0, 'hello')
+ expect(last_log_entry['message']).to include('hello')
+ end
+
+ it 'passes severity, source, and level through' do
+ sink.log(:error, 'mcp', 2, 'test')
+ entry = last_log_entry
+ expect(entry['severity']).to eq('ERROR')
+ expect(entry['source']).to eq('mcp')
+ expect(entry['level']).to eq('2')
+ end
+
+ it 'passes through innocuous messages unchanged' do
+ sink.log(:info, 'mcp', 0, 'connected to host')
+ expect(last_log_entry['message']).to include('connected to host')
+ end
+ end
+
+ describe '#cleanup' do
+ it 'delegates cleanup to the inner sink' do
+ expect(inner_sink).to receive(:cleanup)
+ sink.cleanup
+ end
+ end
+
+ describe 'string sanitization' do
+ it 'redacts password key-value pairs' do
+ sink.log(:info, 'mcp', 0, 'password: hunter2')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('hunter2')
+ end
+
+ it 'redacts password with equals sign' do
+ sink.log(:info, 'mcp', 0, 'password=s3cret')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('s3cret')
+ end
+
+ it 'redacts token key-value pairs' do
+ sink.log(:info, 'mcp', 0, 'token=abc123xyz')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('abc123xyz')
+ end
+
+ it 'redacts bearer tokens' do
+ sink.log(:info, 'mcp', 0, 'bearer eyJhbGci.payload.sig')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('eyJhbGci.payload.sig')
+ end
+
+ it 'redacts token header style' do
+ sink.log(:info, 'mcp', 0, 'token abc123def')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('abc123def')
+ end
+
+ it 'redacts API keys' do
+ sink.log(:info, 'mcp', 0, 'api_key=sk_live_1234567890')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('sk_live_1234567890')
+ end
+
+ it 'redacts secret keys' do
+ sink.log(:info, 'mcp', 0, 'secret_key: my_secret_value')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('my_secret_value')
+ end
+
+ it 'redacts credential values' do
+ sink.log(:info, 'mcp', 0, 'credential=admin_cred')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('admin_cred')
+ end
+
+ it 'redacts auth values' do
+ sink.log(:info, 'mcp', 0, 'auth: some_auth_value')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('some_auth_value')
+ end
+
+ it 'is case-insensitive' do
+ sink.log(:info, 'mcp', 0, 'PASSWORD: upper_case_secret')
+ msg = last_log_entry['message']
+ expect(msg).to include('[REDACTED]')
+ expect(msg).not_to include('upper_case_secret')
+ end
+
+ it 'does not redact non-sensitive strings' do
+ sink.log(:info, 'mcp', 0, 'Module loaded: exploit/windows/smb/ms17_010')
+ expect(last_log_entry['message']).to eq('Module loaded: exploit/windows/smb/ms17_010')
+ end
+ end
+
+ describe 'hash sanitization' do
+ it 'redacts scalar values under sensitive keys' do
+ msg = { message: 'test', context: { password: 'secret123', host: 'localhost' } }
+ sink.log(:info, 'mcp', 0, msg)
+ ctx = last_log_entry['context']
+
+ expect(ctx['password']).to eq('[REDACTED]')
+ expect(ctx['host']).to eq('localhost')
+ end
+
+ it 'redacts all SENSITIVE_KEYS patterns' do
+ %w[password token secret api_key api_secret credential auth_token bearer access_token private_key].each do |key|
+ stream.truncate(0)
+ stream.rewind
+ msg = { message: 'test', context: { key.to_sym => 'sensitive_value' } }
+ sink.log(:info, 'mcp', 0, msg)
+ ctx = last_log_entry['context']
+
+ expect(ctx[key]).to eq('[REDACTED]'), "Expected #{key} to be redacted"
+ end
+ end
+
+ it 'recurses into Hash values under sensitive keys' do
+ msg = { message: 'test', context: { token: { password: 'deep_secret', safe: 'visible' } } }
+ sink.log(:info, 'mcp', 0, msg)
+ ctx = last_log_entry['context']
+
+ # Hash under sensitive key is recursed, not replaced with REDACTED
+ expect(ctx['token']).to be_a(Hash)
+ expect(ctx['token']['password']).to eq('[REDACTED]')
+ expect(ctx['token']['safe']).to eq('visible')
+ end
+
+ it 'recurses into Array values under sensitive keys' do
+ msg = { message: 'test', context: { token: ['value1', 'password: secret'] } }
+ sink.log(:info, 'mcp', 0, msg)
+ ctx = last_log_entry['context']
+
+ expect(ctx['token']).to be_an(Array)
+ # String elements in the array get pattern-based sanitization
+ expect(ctx['token'][1]).to include('[REDACTED]')
+ expect(ctx['token'][1]).not_to include('secret')
+ end
+
+ it 'recurses into non-sensitive hash values' do
+ msg = { message: 'test', context: { data: { password: 'nested_secret' } } }
+ sink.log(:info, 'mcp', 0, msg)
+ ctx = last_log_entry['context']
+
+ expect(ctx['data']['password']).to eq('[REDACTED]')
+ end
+
+ it 'passes through non-string/hash/array types' do
+ msg = { message: 'test', context: { count: 42, enabled: true, value: nil } }
+ sink.log(:info, 'mcp', 0, msg)
+ ctx = last_log_entry['context']
+
+ expect(ctx['count']).to eq(42)
+ expect(ctx['enabled']).to be true
+ expect(ctx['value']).to be_nil
+ end
+
+ it 'sanitizes strings in arrays' do
+ msg = { message: 'test', context: { items: ['safe', 'password=secret', 'also safe'] } }
+ sink.log(:info, 'mcp', 0, msg)
+ items = last_log_entry['context']['items']
+
+ expect(items[0]).to eq('safe')
+ expect(items[1]).to include('[REDACTED]')
+ expect(items[1]).not_to include('secret')
+ expect(items[2]).to eq('also safe')
+ end
+ end
+
+ describe 'exception handling' do
+ let(:error) do
+ StandardError.new('password=s3cret in message').tap do |e|
+ e.set_backtrace([
+ '/opt/metasploit-framework/lib/msf/core/mcp/server.rb:42:in `start`',
+ '/opt/metasploit-framework/lib/msf/core/mcp/application.rb:100:in `run`',
+ '/home/user/lib/custom/code.rb:10:in `call`',
+ 'line4', 'line5', 'line6'
+ ])
+ end
+ end
+
+ it 'formats Exception as structured hash' do
+ msg = { message: 'error occurred', exception: error }
+ sink.log(:error, 'mcp', 0, msg)
+ ex = last_log_entry['exception']
+
+ expect(ex['class']).to eq('StandardError')
+ expect(ex['message']).to be_a(String)
+ end
+
+ it 'sanitizes the exception message' do
+ msg = { message: 'error occurred', exception: error }
+ sink.log(:error, 'mcp', 0, msg)
+ ex = last_log_entry['exception']
+
+ expect(ex['message']).to include('[REDACTED]')
+ expect(ex['message']).not_to include('s3cret')
+ end
+
+ context 'at DEBUG log level' do
+ before do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_3)
+ end
+
+ after do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ end
+
+ it 'includes backtrace' do
+ msg = { message: 'error', exception: error }
+ sink.log(:error, 'mcp', 0, msg)
+ ex = last_log_entry['exception']
+
+ expect(ex['backtrace']).to be_an(Array)
+ end
+
+ it 'limits backtrace to 5 frames' do
+ msg = { message: 'error', exception: error }
+ sink.log(:error, 'mcp', 0, msg)
+ ex = last_log_entry['exception']
+
+ expect(ex['backtrace'].length).to eq(5)
+ end
+
+ it 'strips install path prefix from backtrace frames' do
+ msg = { message: 'error', exception: error }
+ sink.log(:error, 'mcp', 0, msg)
+ bt = last_log_entry['exception']['backtrace']
+
+ expect(bt[0]).to start_with('lib/msf/')
+ expect(bt[0]).not_to include('/opt/metasploit-framework/')
+ end
+
+ it 'sanitizes backtrace strings containing sensitive patterns' do
+ error_with_sensitive_bt = StandardError.new('fail').tap do |e|
+ e.set_backtrace(['lib/msf/core/mcp/server.rb:42:in `token=abc123`'])
+ end
+ msg = { message: 'error', exception: error_with_sensitive_bt }
+ sink.log(:error, 'mcp', 0, msg)
+ bt = last_log_entry['exception']['backtrace']
+
+ expect(bt[0]).to include('[REDACTED]')
+ expect(bt[0]).not_to include('abc123')
+ end
+ end
+
+ context 'below DEBUG log level' do
+ before do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new('/dev/null'), Rex::Logging::LEV_0)
+ end
+
+ after do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ end
+
+ it 'omits backtrace' do
+ msg = { message: 'error', exception: error }
+ sink.log(:error, 'mcp', 0, msg)
+ ex = last_log_entry['exception']
+
+ expect(ex).not_to have_key('backtrace')
+ end
+ end
+
+ it 'handles exception with nil backtrace' do
+ no_bt = RuntimeError.new('no trace')
+ msg = { message: 'error', exception: no_bt }
+ sink.log(:error, 'mcp', 0, msg)
+ ex = last_log_entry['exception']
+
+ expect(ex['class']).to eq('RuntimeError')
+ expect(ex['message']).to eq('no trace')
+ end
+
+ it 'passes non-Exception :exception values through after sanitization' do
+ msg = { message: 'error', exception: 'password=oops' }
+ sink.log(:error, 'mcp', 0, msg)
+
+ expect(last_log_entry['exception']).to include('[REDACTED]')
+ expect(last_log_entry['exception']).not_to include('oops')
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/metasploit/client_spec.rb b/spec/lib/msf/core/mcp/metasploit/client_spec.rb
new file mode 100644
index 0000000000000..c1291cdd4cdd7
--- /dev/null
+++ b/spec/lib/msf/core/mcp/metasploit/client_spec.rb
@@ -0,0 +1,179 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Metasploit::Client do
+ let(:jsonrpc_client) { double('Msf::MCP::Metasploit::JsonRpcClient') }
+ let(:messagepack_client) { double('Msf::MCP::Metasploit::MessagePackClient') }
+
+ describe '#initialize with JSON-RPC' do
+ it 'creates JsonRpcClient for json-rpc api_type' do
+ expect(Msf::MCP::Metasploit::JsonRpcClient).to receive(:new).with(
+ host: 'localhost',
+ port: 8081,
+ ssl: true,
+ endpoint: '/api/v1/json-rpc',
+ token: 'test_token'
+ ).and_return(jsonrpc_client)
+
+ client = described_class.new(
+ api_type: 'json-rpc',
+ host: 'localhost',
+ port: 8081,
+ ssl: true,
+ endpoint: '/api/v1/json-rpc',
+ token: 'test_token'
+ )
+
+ expect(client.instance_variable_get(:@client)).to eq(jsonrpc_client)
+ end
+ end
+
+ describe '#initialize with MessagePack' do
+ it 'creates MessagePackClient for messagepack api_type' do
+ expect(Msf::MCP::Metasploit::MessagePackClient).to receive(:new).with(
+ host: 'localhost',
+ port: 55553,
+ ssl: true,
+ endpoint: '/api/'
+ ).and_return(messagepack_client)
+
+ client = described_class.new(
+ api_type: 'messagepack',
+ host: 'localhost',
+ port: 55553,
+ ssl: true
+ )
+
+ expect(client.instance_variable_get(:@client)).to eq(messagepack_client)
+ end
+ end
+
+ describe '#initialize with invalid API type' do
+ it 'raises Error for unknown api_type' do
+ expect {
+ described_class.new(api_type: 'invalid', host: 'localhost', port: 8081)
+ }.to raise_error(Msf::MCP::Error, /Invalid API type/)
+ end
+ end
+
+ describe '#create_client' do
+ it 'uses default MessagePack endpoint when none provided' do
+ expect(Msf::MCP::Metasploit::MessagePackClient).to receive(:new).with(
+ host: 'localhost',
+ port: 55553,
+ endpoint: Msf::MCP::Metasploit::MessagePackClient::DEFAULT_ENDPOINT,
+ ssl: true
+ )
+
+ described_class.new(api_type: 'messagepack', host: 'localhost', port: 55553)
+ end
+
+ it 'uses default JSON-RPC endpoint when none provided' do
+ expect(Msf::MCP::Metasploit::JsonRpcClient).to receive(:new).with(
+ host: 'localhost',
+ port: 8081,
+ endpoint: Msf::MCP::Metasploit::JsonRpcClient::DEFAULT_ENDPOINT,
+ ssl: true,
+ token: 'tok'
+ )
+
+ described_class.new(api_type: 'json-rpc', host: 'localhost', port: 8081, token: 'tok')
+ end
+
+ it 'uses custom endpoint when provided for MessagePack' do
+ expect(Msf::MCP::Metasploit::MessagePackClient).to receive(:new).with(
+ host: 'localhost',
+ port: 55553,
+ endpoint: '/custom/api/',
+ ssl: false
+ )
+
+ described_class.new(api_type: 'messagepack', host: 'localhost', port: 55553, endpoint: '/custom/api/', ssl: false)
+ end
+
+ it 'uses custom endpoint when provided for JSON-RPC' do
+ expect(Msf::MCP::Metasploit::JsonRpcClient).to receive(:new).with(
+ host: 'remote',
+ port: 9090,
+ endpoint: '/custom/jsonrpc',
+ ssl: false,
+ token: 'my_token'
+ )
+
+ described_class.new(api_type: 'json-rpc', host: 'remote', port: 9090, endpoint: '/custom/jsonrpc', ssl: false, token: 'my_token')
+ end
+
+ it 'defaults ssl to true' do
+ expect(Msf::MCP::Metasploit::MessagePackClient).to receive(:new).with(
+ hash_including(ssl: true)
+ )
+
+ described_class.new(api_type: 'messagepack', host: 'localhost', port: 55553)
+ end
+
+ it 'includes the invalid api_type in the error message' do
+ expect {
+ described_class.new(api_type: 'grpc', host: 'localhost', port: 8081)
+ }.to raise_error(Msf::MCP::Error, 'Invalid API type: grpc')
+ end
+ end
+
+ describe 'method delegation' do
+ let(:client) do
+ allow(Msf::MCP::Metasploit::JsonRpcClient).to receive(:new).and_return(jsonrpc_client)
+ described_class.new(api_type: 'json-rpc', host: 'localhost', port: 8081, token: 'test')
+ end
+
+ it 'delegates search_modules to underlying client' do
+ allow(jsonrpc_client).to receive(:search_modules).with('smb').and_return(['module1'])
+ expect(client.search_modules('smb')).to eq(['module1'])
+ end
+
+ it 'delegates authenticate to underlying client' do
+ allow(jsonrpc_client).to receive(:authenticate).with('user', 'pass').and_return('test')
+ expect(client.authenticate('user', 'pass')).to eq('test')
+ end
+
+ it 'delegates module_info to underlying client' do
+ allow(jsonrpc_client).to receive(:module_info).with('exploit', 'test').and_return({ 'name' => 'test' })
+ expect(client.module_info('exploit', 'test')).to eq({ 'name' => 'test' })
+ end
+
+ it 'delegates db_hosts to underlying client' do
+ allow(jsonrpc_client).to receive(:db_hosts).with({ workspace: 'default' }).and_return({ 'hosts' => [] })
+ expect(client.db_hosts({ workspace: 'default' })).to eq({ 'hosts' => [] })
+ end
+
+ it 'delegates db_services to underlying client' do
+ allow(jsonrpc_client).to receive(:db_services).with({ workspace: 'default' }).and_return({ 'services' => [] })
+ expect(client.db_services({ workspace: 'default' })).to eq({ 'services' => [] })
+ end
+
+ it 'delegates db_vulns to underlying client' do
+ allow(jsonrpc_client).to receive(:db_vulns).with({ workspace: 'default' }).and_return({ 'vulns' => [] })
+ expect(client.db_vulns({ workspace: 'default' })).to eq({ 'vulns' => [] })
+ end
+
+ it 'delegates db_creds to underlying client' do
+ allow(jsonrpc_client).to receive(:db_creds).with({ workspace: 'default' }).and_return({ 'creds' => [] })
+ expect(client.db_creds({ workspace: 'default' })).to eq({ 'creds' => [] })
+ end
+
+ it 'delegates db_loot to underlying client' do
+ allow(jsonrpc_client).to receive(:db_loot).with({ workspace: 'default' }).and_return({ 'loots' => [] })
+ expect(client.db_loot({ workspace: 'default' })).to eq({ 'loots' => [] })
+ end
+
+ it 'delegates db_notes to underlying client' do
+ allow(jsonrpc_client).to receive(:db_notes).with({ workspace: 'default' }).and_return({ 'notes' => [] })
+ expect(client.db_notes({ workspace: 'default' })).to eq({ 'notes' => [] })
+ end
+
+ it 'delegates shutdown to underlying client' do
+ allow(jsonrpc_client).to receive(:shutdown)
+ client.shutdown
+ expect(jsonrpc_client).to have_received(:shutdown)
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/metasploit/jsonrpc_client_spec.rb b/spec/lib/msf/core/mcp/metasploit/jsonrpc_client_spec.rb
new file mode 100644
index 0000000000000..13bf35158789c
--- /dev/null
+++ b/spec/lib/msf/core/mcp/metasploit/jsonrpc_client_spec.rb
@@ -0,0 +1,318 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Metasploit::JsonRpcClient do
+ let(:host) { 'localhost' }
+ let(:port) { 8081 }
+ let(:endpoint) { '/api/v1/json-rpc' }
+ let(:token) { 'test_token_123' }
+ let(:ssl) { false }
+ let(:client) { described_class.new(host: host, port: port, endpoint: endpoint, token: token, ssl: ssl) }
+
+ describe '#initialize' do
+ it 'sets instance variables' do
+ expect(client.instance_variable_get(:@host)).to eq(host)
+ expect(client.instance_variable_get(:@port)).to eq(port)
+ expect(client.instance_variable_get(:@endpoint)).to eq(endpoint)
+ expect(client.instance_variable_get(:@token)).to eq(token)
+ expect(client.instance_variable_get(:@ssl)).to eq(ssl)
+ expect(client.instance_variable_get(:@request_id)).to eq(0)
+ end
+
+ it 'defaults ssl to true when not specified' do
+ client_with_defaults = described_class.new(host: host, port: port, token: token)
+ expect(client_with_defaults.instance_variable_get(:@ssl)).to eq(true)
+ end
+ end
+
+ describe '#authenticate' do
+ it 'is a no-op that returns the existing token' do
+ result = client.authenticate('user', 'pass')
+ expect(result).to eq(token)
+ end
+
+ it 'does not change the token' do
+ client.authenticate('user', 'pass')
+ expect(client.instance_variable_get(:@token)).to eq(token)
+ end
+ end
+
+ describe 'SSL configuration' do
+ let(:http_mock) { instance_double(Net::HTTP) }
+
+ before do
+ allow(Net::HTTP).to receive(:new).and_return(http_mock)
+ allow(http_mock).to receive(:use_ssl=)
+ allow(http_mock).to receive(:verify_mode=)
+ allow(http_mock).to receive(:request).and_return(
+ instance_double(Net::HTTPResponse, code: '200', body: '{"result": {}}')
+ )
+ end
+
+ context 'when ssl is true' do
+ let(:ssl) { true }
+
+ it 'enables SSL on Net::HTTP client' do
+ expect(http_mock).to receive(:use_ssl=).with(true)
+ client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 })
+ end
+
+ it 'sets verify_mode to VERIFY_NONE' do
+ expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
+ client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 })
+ end
+ end
+
+ context 'when ssl is false' do
+ let(:ssl) { false }
+
+ it 'disables SSL on Net::HTTP client' do
+ expect(http_mock).to receive(:use_ssl=).with(false)
+ client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 })
+ end
+
+ it 'does not set verify_mode' do
+ expect(http_mock).not_to receive(:verify_mode=)
+ client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 })
+ end
+ end
+
+ context 'when ssl is explicitly set to true in constructor' do
+ let(:client) { described_class.new(host: host, port: port, token: token, ssl: true) }
+
+ it 'configures HTTPS connection' do
+ expect(http_mock).to receive(:use_ssl=).with(true)
+ expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
+ client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 })
+ end
+ end
+
+ context 'with default SSL setting' do
+ let(:client) { described_class.new(host: host, port: port, token: token) }
+
+ it 'uses SSL by default' do
+ expect(http_mock).to receive(:use_ssl=).with(true)
+ expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
+ client.send(:send_request, { jsonrpc: '2.0', method: 'test', id: 1 })
+ end
+ end
+ end
+
+ describe '#call_api' do
+ before do
+ allow(client).to receive(:send_request).and_return({ 'result' => { 'modules' => [] } })
+ end
+
+ it 'increments request_id' do
+ expect { client.call_api('module.search', ['smb']) }.to change { client.instance_variable_get(:@request_id) }.by(1)
+ end
+
+ it 'sends JSON-RPC 2.0 request with correct structure' do
+ expect(client).to receive(:send_request) do |body|
+ expect(body[:jsonrpc]).to eq('2.0')
+ expect(body[:method]).to eq('module.search')
+ expect(body[:params]).to eq(['smb'])
+ expect(body[:id]).to eq(1)
+ { 'result' => {} }
+ end
+
+ client.call_api('module.search', ['smb'])
+ end
+
+ it 'returns result from response' do
+ result = client.call_api('module.search', ['smb'])
+ expect(result).to eq({ 'modules' => [] })
+ end
+
+ it 'raises ArgumentError when args is not an Array' do
+ expect {
+ client.call_api('module.search', 'smb')
+ }.to raise_error(ArgumentError, /args must be an Array/)
+ end
+
+ it 'raises ArgumentError when args is a Hash' do
+ expect {
+ client.call_api('module.search', { query: 'smb' })
+ }.to raise_error(ArgumentError, /args must be an Array/)
+ end
+
+ it 'raises APIError when response contains error' do
+ allow(client).to receive(:send_request).and_return({ 'error' => { 'message' => 'Invalid method' } })
+
+ expect {
+ client.call_api('invalid.method')
+ }.to raise_error(Msf::MCP::Metasploit::APIError, 'Invalid method')
+ end
+
+ it 'raises APIError with default message when error has no message' do
+ allow(client).to receive(:send_request).and_return({ 'error' => {} })
+
+ expect {
+ client.call_api('invalid.method')
+ }.to raise_error(Msf::MCP::Metasploit::APIError, 'Unknown error')
+ end
+ end
+
+ describe '#shutdown' do
+ it 'finishes HTTP connection if started' do
+ http_mock = double('Net::HTTP')
+ allow(http_mock).to receive(:started?).and_return(true)
+ allow(http_mock).to receive(:finish)
+
+ client.instance_variable_set(:@http, http_mock)
+ client.shutdown
+
+ expect(http_mock).to have_received(:finish)
+ end
+
+ it 'does nothing if HTTP connection not started' do
+ http_mock = double('Net::HTTP')
+ allow(http_mock).to receive(:started?).and_return(false)
+
+ client.instance_variable_set(:@http, http_mock)
+ expect { client.shutdown }.not_to raise_error
+ end
+
+ it 'handles nil HTTP connection' do
+ client.instance_variable_set(:@http, nil)
+ expect { client.shutdown }.not_to raise_error
+ end
+ end
+
+ describe 'debug logging' do
+ let(:http_mock) { instance_double(Net::HTTP) }
+
+ before do
+ allow(Net::HTTP).to receive(:new).and_return(http_mock)
+ allow(http_mock).to receive(:use_ssl=)
+ allow(http_mock).to receive(:verify_mode=)
+ end
+
+ it 'does not raise when no Rex sink is registered' do
+ allow(http_mock).to receive(:request).and_return(
+ instance_double(Net::HTTPResponse, code: '200', body: '{"result": {}}')
+ )
+ expect { client.call_api('module.search', ['smb']) }.not_to raise_error
+ end
+
+ it 'calls dlog for request and response' do
+ allow(http_mock).to receive(:request).and_return(
+ instance_double(Net::HTTPResponse, code: '200', body: '{"result": {}}')
+ )
+
+ expect(client).to receive(:dlog).with(hash_including(message: 'JSON-RPC request'), anything, anything).ordered
+ expect(client).to receive(:dlog).with(hash_including(message: 'JSON-RPC response'), anything, anything).ordered
+
+ client.call_api('module.search', ['smb'])
+ end
+ end
+
+ describe 'API wrapper methods' do
+ describe '#search_modules' do
+ it 'calls call_api with correct method and params' do
+ expect(client).to receive(:call_api).with('module.search', ['smb'])
+ client.search_modules('smb')
+ end
+
+ it 'returns search results' do
+ allow(client).to receive(:call_api).and_return({ 'modules' => [{ 'name' => 'auxiliary/scanner/smb/smb_version' }] })
+ result = client.search_modules('smb')
+ expect(result['modules']).to be_an(Array)
+ end
+ end
+
+ describe '#module_info' do
+ it 'calls call_api with correct method and params' do
+ expect(client).to receive(:call_api).with('module.info', ['exploit', 'windows/smb/ms17_010_eternalblue'])
+ client.module_info('exploit', 'windows/smb/ms17_010_eternalblue')
+ end
+
+ it 'returns module information' do
+ module_data = { 'name' => 'MS17-010 EternalBlue', 'rank' => 'good' }
+ allow(client).to receive(:call_api).and_return(module_data)
+ result = client.module_info('exploit', 'windows/smb/ms17_010_eternalblue')
+ expect(result).to eq(module_data)
+ end
+ end
+
+ describe '#db_hosts' do
+ it 'calls call_api with correct method' do
+ expect(client).to receive(:call_api).with('db.hosts', [{}])
+ client.db_hosts
+ end
+
+ it 'calls call_api with options' do
+ expect(client).to receive(:call_api).with('db.hosts', [{workspace: 'default', limit: 10}])
+ client.db_hosts(workspace: 'default', limit: 10)
+ end
+
+ it 'returns hosts array' do
+ hosts_data = { 'hosts' => [{ 'address' => '192.168.1.1' }] }
+ allow(client).to receive(:call_api).and_return(hosts_data)
+ result = client.db_hosts
+ expect(result['hosts']).to be_an(Array)
+ end
+ end
+
+ describe '#db_services' do
+ it 'calls call_api with correct method' do
+ expect(client).to receive(:call_api).with('db.services', [{}])
+ client.db_services
+ end
+
+ it 'passes options to call_api' do
+ expect(client).to receive(:call_api).with('db.services', [{workspace: 'default'}])
+ client.db_services(workspace: 'default')
+ end
+ end
+
+ describe '#db_vulns' do
+ it 'calls call_api with correct method' do
+ expect(client).to receive(:call_api).with('db.vulns', [{}])
+ client.db_vulns
+ end
+
+ it 'passes options to call_api' do
+ expect(client).to receive(:call_api).with('db.vulns', [{workspace: 'default'}])
+ client.db_vulns(workspace: 'default')
+ end
+ end
+
+ describe '#db_notes' do
+ it 'calls call_api with correct method' do
+ expect(client).to receive(:call_api).with('db.notes', [{}])
+ client.db_notes
+ end
+
+ it 'passes options to call_api' do
+ expect(client).to receive(:call_api).with('db.notes', [{workspace: 'default'}])
+ client.db_notes(workspace: 'default')
+ end
+ end
+
+ describe '#db_creds' do
+ it 'calls call_api with correct method' do
+ expect(client).to receive(:call_api).with('db.creds', [{}])
+ client.db_creds
+ end
+
+ it 'passes options to call_api' do
+ expect(client).to receive(:call_api).with('db.creds', [{workspace: 'default'}])
+ client.db_creds(workspace: 'default')
+ end
+ end
+
+ describe '#db_loot' do
+ it 'calls call_api with correct method' do
+ expect(client).to receive(:call_api).with('db.loots', [{}])
+ client.db_loot
+ end
+
+ it 'passes options to call_api' do
+ expect(client).to receive(:call_api).with('db.loots', [{workspace: 'default'}])
+ client.db_loot(workspace: 'default')
+ end
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb b/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb
new file mode 100644
index 0000000000000..7f5fec20730db
--- /dev/null
+++ b/spec/lib/msf/core/mcp/metasploit/messagepack_client_spec.rb
@@ -0,0 +1,406 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Metasploit::MessagePackClient do
+ let(:host) { 'localhost' }
+ let(:port) { 55553 }
+ let(:client) { described_class.new(host: host, port: port) }
+
+ describe '#initialize' do
+ it 'sets instance variables' do
+ expect(client.instance_variable_get(:@host)).to eq(host)
+ expect(client.instance_variable_get(:@port)).to eq(port)
+ expect(client.instance_variable_get(:@endpoint)).to eq('/api/')
+ expect(client.instance_variable_get(:@token)).to be_nil
+ end
+
+ it 'defaults ssl to true when not specified' do
+ expect(client.instance_variable_get(:@ssl)).to eq(true)
+ end
+
+ it 'accepts ssl parameter' do
+ client_no_ssl = described_class.new(host: host, port: port, ssl: false)
+ expect(client_no_ssl.instance_variable_get(:@ssl)).to eq(false)
+ end
+ end
+
+ describe 'SSL configuration' do
+ let(:http_mock) { instance_double(Net::HTTP) }
+
+ before do
+ allow(Net::HTTP).to receive(:new).and_return(http_mock)
+ allow(http_mock).to receive(:use_ssl=)
+ allow(http_mock).to receive(:verify_mode=)
+ allow(http_mock).to receive(:request).and_return(
+ instance_double(Net::HTTPResponse, code: '200', body: { 'result' => 'success', 'token' => 'test123' }.to_msgpack)
+ )
+ end
+
+ context 'when ssl is true' do
+ let(:client) { described_class.new(host: host, port: port, ssl: true) }
+
+ it 'enables SSL on Net::HTTP client' do
+ expect(http_mock).to receive(:use_ssl=).with(true)
+ client.send(:send_request, ['auth.login', 'user', 'pass'])
+ end
+
+ it 'sets verify_mode to VERIFY_NONE' do
+ expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
+ client.send(:send_request, ['auth.login', 'user', 'pass'])
+ end
+ end
+
+ context 'when ssl is false' do
+ let(:client) { described_class.new(host: host, port: port, ssl: false) }
+
+ it 'disables SSL on Net::HTTP client' do
+ expect(http_mock).to receive(:use_ssl=).with(false)
+ client.send(:send_request, ['auth.login', 'user', 'pass'])
+ end
+
+ it 'does not set verify_mode' do
+ expect(http_mock).not_to receive(:verify_mode=)
+ client.send(:send_request, ['auth.login', 'user', 'pass'])
+ end
+ end
+
+ context 'with default SSL setting' do
+ it 'uses SSL by default' do
+ expect(http_mock).to receive(:use_ssl=).with(true)
+ expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
+ client.send(:send_request, ['auth.login', 'user', 'pass'])
+ end
+ end
+
+ context 'when explicitly set to true' do
+ let(:client) { described_class.new(host: host, port: port, ssl: true) }
+
+ it 'configures HTTPS connection' do
+ expect(http_mock).to receive(:use_ssl=).with(true)
+ expect(http_mock).to receive(:verify_mode=).with(OpenSSL::SSL::VERIFY_NONE)
+ client.send(:send_request, ['auth.login', 'user', 'pass'])
+ end
+ end
+ end
+
+ describe '#authenticate' do
+ it 'sends authentication request with username and password' do
+ expect(client).to receive(:send_request).with(['auth.login', 'testuser', 'testpass']).and_return({ 'result' => 'success', 'token' => 'abc123' })
+
+ client.authenticate('testuser', 'testpass')
+ end
+
+ it 'stores token from response' do
+ allow(client).to receive(:send_request).and_return({ 'result' => 'success', 'token' => 'abc123' })
+ token = client.authenticate('testuser', 'testpass')
+ expect(token).to eq('abc123')
+ end
+
+ it 'raises AuthenticationError when response contains error key' do
+ allow(client).to receive(:send_request).and_return({ 'error' => 'Invalid credentials' })
+
+ expect {
+ client.authenticate('testuser', 'badpass')
+ }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Invalid credentials')
+ end
+
+ it 'raises AuthenticationError with default message when result is not success' do
+ allow(client).to receive(:send_request).and_return({ 'result' => 'failure' })
+
+ expect {
+ client.authenticate('testuser', 'testpass')
+ }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Authentication failed')
+ end
+
+ it 'raises AuthenticationError when send_request raises' do
+ allow(client).to receive(:send_request).and_raise(Msf::MCP::Metasploit::AuthenticationError, 'Login Failed')
+
+ expect {
+ client.authenticate('testuser', 'testpass')
+ }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Login Failed')
+ end
+ end
+
+ describe '#call_api' do
+ before do
+ client.instance_variable_set(:@token, 'abc123')
+ allow(client).to receive(:send_request).and_return(['module1', 'module2'])
+ end
+
+ it 'sends method call with token and arguments' do
+ expect(client).to receive(:send_request).with(['module.search', 'abc123', 'smb']).and_return([])
+
+ client.call_api('module.search', ['smb'])
+ end
+
+ it 'returns result from response' do
+ result = client.call_api('module.search', ['smb'])
+ expect(result).to eq(['module1', 'module2'])
+ end
+
+ it 'raises ArgumentError when args is not an Array' do
+ expect {
+ client.call_api('module.search', 'smb')
+ }.to raise_error(ArgumentError, /args must be an Array/)
+ end
+
+ it 'raises ArgumentError when args is a Hash' do
+ expect {
+ client.call_api('module.search', { query: 'smb' })
+ }.to raise_error(ArgumentError, /args must be an Array/)
+ end
+
+ it 'raises AuthenticationError if no token present and no credentials stored' do
+ client.instance_variable_set(:@token, nil)
+
+ expect {
+ client.call_api('module.search', ['smb'])
+ }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Not authenticated')
+ end
+
+ it 'raises APIError when response contains error' do
+ allow(client).to receive(:send_request).and_raise(Msf::MCP::Metasploit::APIError, 'Method not found')
+
+ expect {
+ client.call_api('module.search', ['smb'])
+ }.to raise_error(Msf::MCP::Metasploit::APIError, 'Method not found')
+ end
+
+ it 'logs via elog before re-raising Msf::MCP::Error subclasses' do
+ allow(client).to receive(:send_request).and_raise(Msf::MCP::Metasploit::APIError, 'Method not found')
+
+ expect(client).to receive(:elog).with(hash_including(message: 'MessagePack API call error'), anything, anything)
+ expect {
+ client.call_api('module.search', ['smb'])
+ }.to raise_error(Msf::MCP::Metasploit::APIError)
+ end
+ end
+
+ describe '#shutdown' do
+ it 'clears token from memory' do
+ client.instance_variable_set(:@token, 'abc123')
+ client.shutdown
+ expect(client.instance_variable_get(:@token)).to be_nil
+ end
+
+ it 'clears stored credentials' do
+ client.instance_variable_set(:@user, 'testuser')
+ client.instance_variable_set(:@password, 'testpass')
+ client.shutdown
+ expect(client.instance_variable_get(:@user)).to be_nil
+ expect(client.instance_variable_get(:@password)).to be_nil
+ end
+
+ it 'finishes HTTP connection if started' do
+ http_mock = double('Net::HTTP')
+ allow(http_mock).to receive(:started?).and_return(true)
+ allow(http_mock).to receive(:finish)
+
+ client.instance_variable_set(:@http, http_mock)
+ client.shutdown
+
+ expect(http_mock).to have_received(:finish)
+ end
+ end
+
+ describe '#sanitize_request_array' do
+ it 'redacts password in auth.login requests' do
+ result = client.send(:sanitize_request_array, ['auth.login', 'admin', 's3cret'])
+ expect(result).to eq(['auth.login', 'admin', '[REDACTED]'])
+ end
+
+ it 'redacts token in API call requests' do
+ result = client.send(:sanitize_request_array, ['module.search', 'tok_abc123', 'smb'])
+ expect(result).to eq(['module.search', '[REDACTED]', 'smb'])
+ end
+
+ it 'does not mutate the original array' do
+ original = ['auth.login', 'admin', 's3cret']
+ client.send(:sanitize_request_array, original)
+ expect(original).to eq(['auth.login', 'admin', 's3cret'])
+ end
+
+ it 'handles single-element arrays' do
+ result = client.send(:sanitize_request_array, ['auth.logout'])
+ expect(result).to eq(['auth.logout'])
+ end
+ end
+
+ describe 'debug logging' do
+ let(:http_mock) { instance_double(Net::HTTP) }
+
+ before do
+ allow(Net::HTTP).to receive(:new).and_return(http_mock)
+ allow(http_mock).to receive(:use_ssl=)
+ end
+
+ it 'does not raise when no Rex sink is registered' do
+ client_no_ssl = described_class.new(host: host, port: port, ssl: false)
+ allow(http_mock).to receive(:request).and_return(
+ instance_double(Net::HTTPResponse, code: '200', body: { 'result' => 'success', 'token' => 'abc' }.to_msgpack)
+ )
+ expect { client_no_ssl.authenticate('user', 'pass') }.not_to raise_error
+ end
+
+ it 'calls dlog for request and response' do
+ client_no_ssl = described_class.new(host: host, port: port, ssl: false)
+ allow(http_mock).to receive(:request).and_return(
+ instance_double(Net::HTTPResponse, code: '200', body: { 'result' => 'success', 'token' => 'abc' }.to_msgpack)
+ )
+
+ expect(client_no_ssl).to receive(:dlog).with(hash_including(message: 'MessagePack request'), anything, anything).ordered
+ expect(client_no_ssl).to receive(:dlog).with(hash_including(message: 'MessagePack response'), anything, anything).ordered
+
+ client_no_ssl.authenticate('user', 'pass')
+ end
+ end
+
+ describe 'automatic re-authentication' do
+ before do
+ # Initial authentication
+ allow(client).to receive(:send_request).with(['auth.login', 'testuser', 'testpass']).and_return(
+ { 'result' => 'success', 'token' => 'initial_token' }
+ )
+
+ client.authenticate('testuser', 'testpass')
+ end
+
+ it 'automatically re-authenticates on invalid token error' do
+ call_count = 0
+
+ allow(client).to receive(:send_request) do |request_array|
+ call_count += 1
+
+ case call_count
+ when 1
+ # First API call raises AuthenticationError (simulating HTTP 401)
+ raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token'
+ when 2
+ # Re-authentication request succeeds
+ { 'result' => 'success', 'token' => 'refreshed_token' }
+ when 3
+ # Retry with new token succeeds
+ { 'modules' => [] }
+ else
+ raise 'Unexpected request sequence'
+ end
+ end
+
+ result = client.search_modules('smb')
+ expect(result).to eq({ 'modules' => [] })
+ expect(client.instance_variable_get(:@token)).to eq('refreshed_token')
+ end
+
+ it 'stops retrying after max_retries when API calls keep failing' do
+ retry_attempt = 0
+
+ allow(client).to receive(:send_request) do |request_array|
+ if request_array[0] == 'auth.login'
+ # Re-authentication succeeds
+ { 'result' => 'success', 'token' => 'new_token' }
+ else
+ # Always raise AuthenticationError for API calls
+ retry_attempt += 1
+ raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token'
+ end
+ end
+
+ # After exhausting retries (max_retries=2), re-raises the last error
+ expect {
+ client.search_modules('smb')
+ }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Invalid token')
+
+ # initial call + 2 retries = 3 API attempts
+ expect(retry_attempt).to eq(3)
+ end
+
+ it 'does not auto-reauth if credentials not stored' do
+ # Create new client without authenticating
+ new_client = described_class.new(host: host, port: port)
+ new_client.instance_variable_set(:@token, 'some_token')
+
+ allow(new_client).to receive(:send_request).and_raise(Msf::MCP::Metasploit::AuthenticationError, 'Invalid token')
+
+ expect {
+ new_client.search_modules('smb')
+ }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, 'Invalid token')
+ end
+
+ it 'logs wlog when attempting re-authentication' do
+ call_count = 0
+ allow(client).to receive(:send_request) do |request_array|
+ call_count += 1
+ case call_count
+ when 1
+ raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token'
+ when 2
+ { 'result' => 'success', 'token' => 'new_token' }
+ when 3
+ { 'modules' => [] }
+ end
+ end
+
+ expect(client).to receive(:wlog).with(hash_including(message: /Attempting to re-authenticate/), anything, anything)
+ client.search_modules('smb')
+ end
+
+ it 'raises with descriptive message when re-authentication itself fails' do
+ allow(client).to receive(:send_request) do |request_array|
+ if request_array[0] == 'auth.login'
+ raise Msf::MCP::Metasploit::AuthenticationError, 'Bad credentials'
+ else
+ raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token'
+ end
+ end
+
+ expect {
+ client.search_modules('smb')
+ }.to raise_error(Msf::MCP::Metasploit::AuthenticationError, /Unable to authenticate after 2 attempts: Bad credentials/)
+ end
+
+ it 'resets retry count after successful re-authentication' do
+ call_sequence = []
+
+ allow(client).to receive(:send_request) do |request_array|
+ if request_array[0] == 'module.search'
+ if call_sequence.count { |c| c == :search_call } == 0
+ call_sequence << :search_call
+ # First search call fails
+ raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token'
+ else
+ call_sequence << :search_retry
+ # Retry succeeds
+ { 'modules' => ['mod1'] }
+ end
+ elsif request_array[0] == 'auth.login'
+ call_sequence << :reauth
+ { 'result' => 'success', 'token' => "token#{call_sequence.length}" }
+ elsif request_array[0] == 'db.hosts'
+ if call_sequence.count { |c| c == :hosts_call } == 0
+ call_sequence << :hosts_call
+ # First hosts call fails
+ raise Msf::MCP::Metasploit::AuthenticationError, 'Invalid token'
+ else
+ call_sequence << :hosts_retry
+ # Retry succeeds
+ { 'hosts' => [] }
+ end
+ else
+ raise "Unexpected request: #{request_array[0]}"
+ end
+ end
+
+ # First call with auto-reauth
+ result1 = client.search_modules('smb')
+ expect(result1).to eq({ 'modules' => ['mod1'] })
+
+ # Second call with auto-reauth (retry count should have been reset)
+ result2 = client.db_hosts({})
+ expect(result2).to eq({ 'hosts' => [] })
+
+ # Verify the sequence: search fail, reauth, search retry, hosts fail, reauth, hosts retry
+ expect(call_sequence).to eq([:search_call, :reauth, :search_retry, :hosts_call, :reauth, :hosts_retry])
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/metasploit/response_transformer_spec.rb b/spec/lib/msf/core/mcp/metasploit/response_transformer_spec.rb
new file mode 100644
index 0000000000000..79b2ebf70bb49
--- /dev/null
+++ b/spec/lib/msf/core/mcp/metasploit/response_transformer_spec.rb
@@ -0,0 +1,545 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Metasploit::ResponseTransformer do
+ describe '.transform_modules' do
+ it 'transforms valid module array' do
+ modules = [
+ {
+ 'name' => 'ms17_010_eternalblue',
+ 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue',
+ 'type' => 'exploit',
+ 'rank' => 'excellent',
+ 'disclosuredate' => '2017-03-14',
+ 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption'
+ }
+ ]
+
+ result = described_class.transform_modules(modules)
+
+ expect(result).to be_an(Array)
+ expect(result.length).to eq(1)
+ expect(result[0]).to include(
+ name: 'ms17_010_eternalblue',
+ fullname: 'exploit/windows/smb/ms17_010_eternalblue',
+ type: 'exploit',
+ rank: 'excellent',
+ disclosure_date: '2017-03-14'
+ )
+ # Note: description is not included in transform_modules output
+ end
+
+ it 'handles nil input' do
+ expect(described_class.transform_modules(nil)).to eq([])
+ end
+
+ it 'handles empty array' do
+ expect(described_class.transform_modules([])).to eq([])
+ end
+
+ it 'handles modules with missing fields' do
+ modules = [{ 'name' => 'test', 'type' => 'exploit' }]
+ result = described_class.transform_modules(modules)
+
+ expect(result[0]).to include(name: 'test', type: 'exploit')
+ expect(result[0]).not_to have_key(:description)
+ end
+
+ it 'uses fullname as name if name is missing' do
+ modules = [{ 'fullname' => 'exploit/test', 'type' => 'exploit' }]
+ result = described_class.transform_modules(modules)
+
+ expect(result[0][:name]).to eq('exploit/test')
+ end
+ end
+
+ describe '.transform_module_info' do
+ let(:module_info) do
+ {
+ 'type' => 'exploit',
+ 'name' => 'ms17_010_eternalblue',
+ 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue',
+ 'rank' => 'excellent',
+ 'disclosuredate' => '2017-03-14',
+ 'description' => 'Test description',
+ 'license' => 'MSF_LICENSE',
+ 'filepath' => '/opt/metasploit-framework/modules/exploits/windows/smb/ms17_010.rb',
+ 'arch' => ['x64', 'x86'],
+ 'platform' => ['windows'],
+ 'authors' => ['Author 1', 'Author 2'],
+ 'privileged' => true,
+ 'check' => true,
+ 'default_options' => { 'Option1' => 'Value1' },
+ 'references' => [{'CVE' => '2017-0144'}, {'URL' => 'https://example.com'}],
+ 'targets' => { 0 => 'Windows 7', 1 => 'Windows 8' },
+ 'default_target' => 0,
+ 'stance' => 'aggressive',
+ 'actions' => { 0 => 'Action1', 1 => 'Action2' },
+ 'default_action' => 1,
+ 'options' => { 'RHOST' => '127.0.0.1', 'RPORT' => 445 }
+ }
+ end
+
+ it 'transforms complete module info' do
+ result = described_class.transform_module_info(module_info)
+
+ expect(result).to include(
+ type: 'exploit',
+ name: 'ms17_010_eternalblue',
+ fullname: 'exploit/windows/smb/ms17_010_eternalblue',
+ rank: 'excellent',
+ disclosure_date: '2017-03-14',
+ description: 'Test description',
+ license: 'MSF_LICENSE',
+ filepath: 'modules/exploits/windows/smb/ms17_010.rb',
+ architectures: ['x64', 'x86'],
+ platforms: ['windows'],
+ authors: ['Author 1', 'Author 2'],
+ privileged: true,
+ has_check_method: true,
+ default_options: { 'Option1' => 'Value1' },
+ references: [{'CVE' => '2017-0144'}, {'URL' => 'https://example.com'}],
+ targets: { 0 => 'Windows 7', 1 => 'Windows 8' },
+ default_target: 0,
+ stance: 'aggressive',
+ actions: { 0 => 'Action1', 1 => 'Action2' },
+ default_action: 1,
+ options: { 'RHOST' => '127.0.0.1', 'RPORT' => 445 }
+ )
+ end
+
+ it 'transforms references array' do
+ result = described_class.transform_module_info(module_info)
+
+ expect(result[:references]).to be_an(Array)
+ # Note: references are passed through as-is, not transformed to {type:, value:} format
+ expect(result[:references]).to eq([
+ {'CVE' => '2017-0144'},
+ {'URL' => 'https://example.com'}
+ ])
+ end
+
+ it 'handles nil input' do
+ expect(described_class.transform_module_info(nil)).to eq({})
+ end
+
+ it 'handles empty hash' do
+ expect(described_class.transform_module_info({})).to eq({})
+ end
+
+ it 'compacts nil values' do
+ minimal_info = { 'name' => 'test', 'filepath' => 'modules/exploits/test.rb' }
+ result = described_class.transform_module_info(minimal_info)
+
+ expect(result[:name]).to eq('test')
+ expect(result[:filepath]).to eq('modules/exploits/test.rb')
+ expect(result).not_to have_key(:description)
+ end
+
+ it 'strips the install path prefix from filepath' do
+ info = { 'name' => 'test', 'filepath' => '/home/user/.msf4/modules/post/linux/gather/enum_configs.rb' }
+ result = described_class.transform_module_info(info)
+
+ expect(result[:filepath]).to eq('modules/post/linux/gather/enum_configs.rb')
+ end
+
+ it 'handles nil filepath via safe navigation' do
+ info = { 'name' => 'test', 'filepath' => nil }
+ result = described_class.transform_module_info(info)
+
+ expect(result).not_to have_key(:filepath)
+ end
+
+ it 'passes through filepath that already starts with modules/' do
+ info = { 'name' => 'test', 'filepath' => 'modules/exploits/test.rb' }
+ result = described_class.transform_module_info(info)
+
+ expect(result[:filepath]).to eq('modules/exploits/test.rb')
+ end
+ end
+
+ describe '.transform_hosts' do
+ let(:hosts_response) do
+ {
+ 'hosts' => [
+ {
+ 'address' => '192.168.1.100',
+ 'mac' => '00:11:22:33:44:55',
+ 'name' => 'testhost',
+ 'os_name' => 'Linux',
+ 'os_flavor' => 'Ubuntu',
+ 'os_sp' => '20.04',
+ 'os_lang' => 'English',
+ 'purpose' => 'server',
+ 'info' => 'Web server',
+ 'state' => 'alive',
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ }
+ ]
+ }
+ end
+
+ it 'transforms hosts with timestamps' do
+ result = described_class.transform_hosts(hosts_response)
+
+ expect(result).to be_an(Array)
+ expect(result.length).to eq(1)
+ expect(result[0]).to include(
+ address: '192.168.1.100',
+ mac_address: '00:11:22:33:44:55',
+ hostname: 'testhost',
+ os_name: 'Linux',
+ os_flavor: 'Ubuntu',
+ state: 'alive'
+ )
+ expect(result[0][:created_at]).to eq('2021-01-01T00:00:00Z')
+ expect(result[0][:updated_at]).to eq('2022-01-01T00:00:00Z')
+ end
+
+ it 'handles nil input' do
+ expect(described_class.transform_hosts(nil)).to eq([])
+ end
+
+ it 'handles missing hosts array' do
+ expect(described_class.transform_hosts({})).to eq([])
+ end
+
+ it 'handles empty hosts array' do
+ expect(described_class.transform_hosts({ 'hosts' => [] })).to eq([])
+ end
+
+ it 'handles hosts with missing fields' do
+ minimal_response = { 'hosts' => [{ 'address' => '192.168.1.1' }] }
+ result = described_class.transform_hosts(minimal_response)
+
+ expect(result[0]).to eq({ address: '192.168.1.1' })
+ end
+
+ it 'handles nil timestamps' do
+ response = { 'hosts' => [{ 'address' => '192.168.1.1', 'created_at' => nil }] }
+ result = described_class.transform_hosts(response)
+
+ expect(result[0]).not_to have_key(:created_at)
+ end
+
+ it 'handles zero timestamps' do
+ response = { 'hosts' => [{ 'address' => '192.168.1.1', 'created_at' => 0 }] }
+ result = described_class.transform_hosts(response)
+
+ expect(result[0]).not_to have_key(:created_at)
+ end
+ end
+
+ describe '.transform_services' do
+ let(:services_response) do
+ {
+ 'services' => [
+ {
+ 'host' => '192.168.1.100',
+ 'port' => 80,
+ 'proto' => 'tcp',
+ 'state' => 'open',
+ 'name' => 'http',
+ 'info' => 'Apache httpd 2.4.41',
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ }
+ ]
+ }
+ end
+
+ it 'transforms services' do
+ result = described_class.transform_services(services_response)
+
+ expect(result).to be_an(Array)
+ expect(result[0]).to include(
+ host_address: '192.168.1.100',
+ port: 80,
+ protocol: 'tcp',
+ state: 'open',
+ name: 'http',
+ info: 'Apache httpd 2.4.41'
+ )
+ end
+
+ it 'handles nil input' do
+ expect(described_class.transform_services(nil)).to eq([])
+ end
+
+ it 'handles empty services array' do
+ expect(described_class.transform_services({ 'services' => [] })).to eq([])
+ end
+ end
+
+ describe '.transform_vulns' do
+ let(:vulns_response) do
+ {
+ 'vulns' => [
+ {
+ 'host' => '192.168.1.100',
+ 'port' => 445,
+ 'proto' => 'tcp',
+ 'name' => 'MS17-010',
+ 'info' => 'SMB vulnerability',
+ 'refs' => 'CVE-2017-0144,MSB-2017-010',
+ 'time' => 1609459200
+ }
+ ]
+ }
+ end
+
+ it 'transforms vulnerabilities' do
+ result = described_class.transform_vulns(vulns_response)
+
+ expect(result).to be_an(Array)
+ expect(result[0]).to include(
+ host: '192.168.1.100',
+ port: 445,
+ protocol: 'tcp',
+ name: 'MS17-010'
+ )
+ expect(result[0][:references]).to eq(['CVE-2017-0144', 'MSB-2017-010'])
+ expect(result[0][:created_at]).to eq('2021-01-01T00:00:00Z')
+ # Note: 'info' field is not included in transform_vulns output
+ end
+
+ it 'handles nil input' do
+ expect(described_class.transform_vulns(nil)).to eq([])
+ end
+
+ it 'handles empty vulns array' do
+ expect(described_class.transform_vulns({ 'vulns' => [] })).to eq([])
+ end
+
+ it 'handles nil refs' do
+ response = { 'vulns' => [{ 'host' => '192.168.1.1', 'refs' => nil }] }
+ result = described_class.transform_vulns(response)
+
+ expect(result[0]).not_to have_key(:refs)
+ end
+
+ it 'handles empty refs string' do
+ response = { 'vulns' => [{ 'host' => '192.168.1.1', 'refs' => '' }] }
+ result = described_class.transform_vulns(response)
+
+ expect(result[0]).not_to have_key(:refs)
+ end
+ end
+
+ describe '.transform_notes' do
+ let(:notes_response) do
+ {
+ 'notes' => [
+ {
+ 'host' => '192.168.1.100',
+ 'service' => 'http',
+ 'type' => 'web.form',
+ 'data' => 'Login form found',
+ 'critical' => false,
+ 'seen' => true,
+ 'time' => 1609459200
+ }
+ ]
+ }
+ end
+
+ it 'transforms notes' do
+ result = described_class.transform_notes(notes_response)
+
+ expect(result).to be_an(Array)
+ expect(result[0]).to include(
+ host: '192.168.1.100',
+ service_name_or_port: 'http',
+ note_type: 'web.form',
+ data: 'Login form found'
+ )
+ expect(result[0][:created_at]).to eq('2021-01-01T00:00:00Z')
+ # Note: 'critical' and 'seen' fields are not included in transform_notes output
+ end
+
+ it 'handles nil input' do
+ expect(described_class.transform_notes(nil)).to eq([])
+ end
+
+ it 'handles empty notes array' do
+ expect(described_class.transform_notes({ 'notes' => [] })).to eq([])
+ end
+
+ it 'handles ntype as fallback for type' do
+ response = { 'notes' => [{ 'host' => '192.168.1.1', 'ntype' => 'test' }] }
+ result = described_class.transform_notes(response)
+
+ expect(result[0][:note_type]).to eq('test')
+ end
+ end
+
+ describe '.transform_creds' do
+ let(:creds_response) do
+ {
+ 'creds' => [
+ {
+ 'host' => '192.168.1.100',
+ 'port' => 22,
+ 'proto' => 'tcp',
+ 'sname' => 'ssh',
+ 'user' => 'admin',
+ 'pass' => 'password123',
+ 'type' => 'password',
+ 'updated_at' => 1609459200
+ }
+ ]
+ }
+ end
+
+ it 'transforms credentials' do
+ result = described_class.transform_creds(creds_response)
+
+ expect(result).to be_an(Array)
+ expect(result[0]).to include(
+ host: '192.168.1.100',
+ port: 22,
+ protocol: 'tcp',
+ service_name: 'ssh',
+ user: 'admin',
+ secret: 'password123',
+ type: 'password'
+ )
+ expect(result[0][:updated_at]).to eq('2021-01-01T00:00:00Z')
+ end
+
+ it 'handles nil input' do
+ expect(described_class.transform_creds(nil)).to eq([])
+ end
+
+ it 'handles empty creds array' do
+ expect(described_class.transform_creds({ 'creds' => [] })).to eq([])
+ end
+ end
+
+ describe '.transform_loot' do
+ let(:loot_response) do
+ {
+ 'loots' => [
+ {
+ 'host' => '192.168.1.100',
+ 'service' => 'http',
+ 'ltype' => 'credentials',
+ 'ctype' => 'text/plain',
+ 'name' => 'passwords.txt',
+ 'info' => 'Recovered passwords',
+ 'data' => 'user1:pass1',
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ }
+ ]
+ }
+ end
+
+ it 'transforms loot' do
+ result = described_class.transform_loot(loot_response)
+
+ expect(result).to be_an(Array)
+ expect(result[0]).to include(
+ host: '192.168.1.100',
+ service_name_or_port: 'http',
+ loot_type: 'credentials',
+ content_type: 'text/plain',
+ name: 'passwords.txt',
+ info: 'Recovered passwords',
+ data: 'user1:pass1'
+ )
+ expect(result[0][:created_at]).to eq('2021-01-01T00:00:00Z')
+ expect(result[0][:updated_at]).to eq('2022-01-01T00:00:00Z')
+ end
+
+ it 'handles nil input' do
+ expect(described_class.transform_loot(nil)).to eq([])
+ end
+
+ it 'handles empty loots array' do
+ expect(described_class.transform_loot({ 'loots' => [] })).to eq([])
+ end
+ end
+
+ describe '.format_timestamp' do
+ it 'converts Unix epoch to ISO 8601' do
+ timestamp = 1609459200 # 2021-01-01 00:00:00 UTC
+ result = described_class.send(:format_timestamp, timestamp)
+
+ expect(result).to eq('2021-01-01T00:00:00Z')
+ end
+
+ it 'handles nil' do
+ expect(described_class.send(:format_timestamp, nil)).to be_nil
+ end
+
+ it 'handles zero' do
+ expect(described_class.send(:format_timestamp, 0)).to be_nil
+ end
+
+ it 'handles string timestamps' do
+ result = described_class.send(:format_timestamp, '1609459200')
+ expect(result).to eq('2021-01-01T00:00:00Z')
+ end
+ end
+
+ describe '.transform_references' do
+ it 'transforms array of arrays' do
+ refs = [['CVE', '2017-0144'], ['URL', 'https://example.com']]
+ result = described_class.send(:transform_references, refs)
+
+ expect(result).to eq([
+ { type: 'CVE', value: '2017-0144' },
+ { type: 'URL', value: 'https://example.com' }
+ ])
+ end
+
+ it 'handles nil' do
+ expect(described_class.send(:transform_references, nil)).to be_nil
+ end
+
+ it 'handles empty array' do
+ expect(described_class.send(:transform_references, [])).to eq([])
+ end
+
+ it 'passes through malformed references' do
+ refs = ['invalid', { type: 'custom' }]
+ result = described_class.send(:transform_references, refs)
+
+ expect(result).to eq(['invalid', { type: 'custom' }])
+ end
+ end
+
+ describe '.parse_refs' do
+ it 'parses comma-separated string' do
+ refs = 'CVE-2017-0144,MS17-010,OSVDB-12345'
+ result = described_class.send(:parse_refs, refs)
+
+ expect(result).to eq(['CVE-2017-0144', 'MS17-010', 'OSVDB-12345'])
+ end
+
+ it 'strips whitespace' do
+ refs = 'CVE-2017-0144, MS17-010 , OSVDB-12345'
+ result = described_class.send(:parse_refs, refs)
+
+ expect(result).to eq(['CVE-2017-0144', 'MS17-010', 'OSVDB-12345'])
+ end
+
+ it 'handles nil' do
+ expect(described_class.send(:parse_refs, nil)).to be_nil
+ end
+
+ it 'handles empty string' do
+ expect(described_class.send(:parse_refs, '')).to be_nil
+ end
+
+ it 'filters empty elements' do
+ refs = 'CVE-2017-0144,,,MS17-010'
+ result = described_class.send(:parse_refs, refs)
+
+ expect(result).to eq(['CVE-2017-0144', 'MS17-010'])
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/middleware/request_logger_spec.rb b/spec/lib/msf/core/mcp/middleware/request_logger_spec.rb
new file mode 100644
index 0000000000000..55ad8544fd1ea
--- /dev/null
+++ b/spec/lib/msf/core/mcp/middleware/request_logger_spec.rb
@@ -0,0 +1,305 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'tempfile'
+require 'json'
+require 'rack'
+
+RSpec.describe Msf::MCP::Middleware::RequestLogger do
+ let(:log_file) { Tempfile.new(['request_logger_test', '.log']).tap(&:close).path }
+ let(:log_source) { Msf::MCP::LOG_SOURCE }
+
+ # A simple Rack app that returns a configurable response
+ let(:response_status) { 200 }
+ let(:response_headers) { { 'Content-Type' => 'application/json' } }
+ let(:response_body) { ['{"jsonrpc":"2.0","id":1,"result":{}}'] }
+ let(:inner_app) { ->(_env) { [response_status, response_headers, response_body] } }
+ let(:middleware) { described_class.new(inner_app) }
+
+ before do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ register_log_source(log_source, Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file), Rex::Logging::LEV_3)
+ end
+
+ after do
+ deregister_log_source(log_source) if log_source_registered?(log_source)
+ File.delete(log_file) if File.exist?(log_file)
+ end
+
+ # Helper: parse the last JSON log entry
+ def last_log_entry
+ JSON.parse(File.read(log_file).strip.split("\n").last)
+ end
+
+ # Helper: build a minimal Rack env for a given HTTP method
+ def rack_env_for(method, body: nil, headers: {})
+ env = Rack::MockRequest.env_for('http://localhost:3000/mcp', method: method)
+ if body
+ io = StringIO.new(body)
+ env['rack.input'] = io
+ end
+ headers.each do |key, value|
+ rack_key = "HTTP_#{key.upcase.tr('-', '_')}"
+ env[rack_key] = value
+ end
+ env
+ end
+
+ describe '#call' do
+ it 'delegates to the inner app and returns its response' do
+ env = rack_env_for('POST', body: '{"jsonrpc":"2.0","method":"ping","id":1}')
+ status, headers, body = middleware.call(env)
+
+ expect(status).to eq(200)
+ expect(headers).to eq(response_headers)
+ expect(body).to eq(response_body)
+ end
+
+ it 'logs after the inner app responds' do
+ env = rack_env_for('POST', body: '{"jsonrpc":"2.0","method":"ping","id":1}')
+ middleware.call(env)
+
+ expect(File.read(log_file)).not_to be_empty
+ end
+ end
+
+ describe 'POST requests' do
+ context 'with a normal JSON-RPC request' do
+ it 'logs at DEBUG level with method and id' do
+ body = '{"jsonrpc":"2.0","method":"tools/call","id":42,"params":{"name":"test"}}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('DEBUG')
+ expect(entry['message']).to include('tools/call')
+ expect(entry['message']).to include('id=42')
+ end
+
+ it 'includes elapsed time in the message' do
+ body = '{"jsonrpc":"2.0","method":"ping","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+
+ expect(last_log_entry['message']).to match(/\d+(\.\d+)?ms/)
+ end
+
+ it 'includes request fields in context' do
+ body = '{"jsonrpc":"2.0","method":"tools/call","id":1,"params":{"name":"test"}}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ ctx = last_log_entry['context']
+
+ expect(ctx['request']['method']).to eq('tools/call')
+ expect(ctx['request']['id']).to eq(1)
+ end
+
+ it 'includes response status in context' do
+ body = '{"jsonrpc":"2.0","method":"ping","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ ctx = last_log_entry['context']
+
+ expect(ctx['response']['status']).to eq(200)
+ end
+
+ it 'includes response result in context' do
+ body = '{"jsonrpc":"2.0","method":"ping","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ ctx = last_log_entry['context']
+
+ expect(ctx['response']['result']).to eq({})
+ end
+ end
+
+ context 'with a notification (no id)' do
+ it 'logs at DEBUG level as a notification' do
+ body = '{"jsonrpc":"2.0","method":"notifications/initialized"}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('DEBUG')
+ expect(entry['message']).to include('Notification')
+ expect(entry['message']).to include('notifications/initialized')
+ end
+ end
+
+ context 'with an HTTP error response' do
+ let(:response_status) { 400 }
+ let(:response_body) { ['{"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"}}'] }
+
+ it 'logs at ERROR level' do
+ body = '{"jsonrpc":"2.0","method":"invalid","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('ERROR')
+ expect(entry['message']).to include('400')
+ expect(entry['message']).to include('invalid')
+ end
+
+ it 'includes error details in response context' do
+ body = '{"jsonrpc":"2.0","method":"invalid","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ ctx = last_log_entry['context']
+
+ expect(ctx['response']['error']).to be_a(Hash)
+ expect(ctx['response']['error']['message']).to eq('Method not found')
+ end
+ end
+
+ context 'with invalid JSON in request body' do
+ it 'logs with unknown method name' do
+ env = rack_env_for('POST', body: 'not valid json{{{')
+ middleware.call(env)
+
+ expect(last_log_entry['message']).to include('unknown')
+ end
+ end
+
+ context 'with empty response body' do
+ let(:response_body) { [] }
+
+ it 'does not include result or error in response context' do
+ body = '{"jsonrpc":"2.0","method":"ping","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ ctx = last_log_entry['context']
+
+ expect(ctx['response']).not_to have_key('result')
+ expect(ctx['response']).not_to have_key('error')
+ end
+ end
+
+ context 'with non-Array response body (SSE stream)' do
+ let(:response_body) { proc { |_| } }
+
+ it 'does not include result or error in response context' do
+ body = '{"jsonrpc":"2.0","method":"tools/call","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ ctx = last_log_entry['context']
+
+ expect(ctx['response']).not_to have_key('result')
+ expect(ctx['response']).not_to have_key('error')
+ end
+ end
+
+ context 'with invalid JSON in response body' do
+ let(:response_body) { ['not json{{{'] }
+
+ it 'does not include result or error in response context' do
+ body = '{"jsonrpc":"2.0","method":"ping","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+ ctx = last_log_entry['context']
+
+ expect(ctx['response']).not_to have_key('result')
+ expect(ctx['response']).not_to have_key('error')
+ end
+ end
+ end
+
+ describe 'GET requests' do
+ it 'logs SSE stream opened at INFO level' do
+ env = rack_env_for('GET')
+ middleware.call(env)
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('INFO')
+ expect(entry['message']).to include('SSE stream opened')
+ end
+
+ it 'includes elapsed time' do
+ env = rack_env_for('GET')
+ middleware.call(env)
+
+ expect(last_log_entry['message']).to match(/\d+(\.\d+)?ms/)
+ end
+
+ it 'includes session_id from header when present' do
+ env = rack_env_for('GET', headers: { 'Mcp-Session-Id' => 'sess-abc' })
+ middleware.call(env)
+
+ expect(last_log_entry['context']['session_id']).to eq('sess-abc')
+ end
+
+ it 'includes response status in context' do
+ env = rack_env_for('GET')
+ middleware.call(env)
+
+ expect(last_log_entry['context']['response']['status']).to eq(200)
+ end
+ end
+
+ describe 'DELETE requests' do
+ it 'logs session deleted at INFO level' do
+ env = rack_env_for('DELETE')
+ middleware.call(env)
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('INFO')
+ expect(entry['message']).to include('Session deleted')
+ end
+ end
+
+ describe 'other HTTP methods' do
+ it 'logs at DEBUG level with method name and status' do
+ env = rack_env_for('OPTIONS')
+ middleware.call(env)
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('DEBUG')
+ expect(entry['message']).to include('OPTIONS')
+ expect(entry['message']).to include('200')
+ end
+ end
+
+ describe 'session ID extraction' do
+ it 'extracts session_id from request header' do
+ body = '{"jsonrpc":"2.0","method":"ping","id":1}'
+ env = rack_env_for('POST', body: body, headers: { 'Mcp-Session-Id' => 'req-sess' })
+ middleware.call(env)
+
+ expect(last_log_entry['context']['session_id']).to eq('req-sess')
+ end
+
+ it 'falls back to session_id from response header' do
+ response_headers['Mcp-Session-Id'] = 'resp-sess'
+ body = '{"jsonrpc":"2.0","method":"ping","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+
+ expect(last_log_entry['context']['session_id']).to eq('resp-sess')
+ end
+
+ it 'omits session_id when not present' do
+ body = '{"jsonrpc":"2.0","method":"ping","id":1}'
+ env = rack_env_for('POST', body: body)
+ middleware.call(env)
+
+ expect(last_log_entry['context']).not_to have_key('session_id')
+ end
+ end
+
+ describe 'content type in response context' do
+ it 'includes Content-Type when present' do
+ env = rack_env_for('GET')
+ middleware.call(env)
+
+ expect(last_log_entry['context']['response']['content_type']).to eq('application/json')
+ end
+
+ it 'omits Content-Type when not present' do
+ response_headers.delete('Content-Type')
+ env = rack_env_for('GET')
+ middleware.call(env)
+
+ expect(last_log_entry['context']['response']).not_to have_key('content_type')
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/rpc_manager_spec.rb b/spec/lib/msf/core/mcp/rpc_manager_spec.rb
new file mode 100644
index 0000000000000..4f3393402dbfa
--- /dev/null
+++ b/spec/lib/msf/core/mcp/rpc_manager_spec.rb
@@ -0,0 +1,1038 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+require 'socket'
+require 'stringio'
+
+RSpec.describe Msf::MCP::RpcManager do
+ let(:output) { StringIO.new }
+ let(:default_config) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 55553,
+ ssl: true,
+ endpoint: '/api/',
+ user: 'testuser',
+ password: 'testpass',
+ auto_start_rpc: true
+ }
+ }
+ end
+
+ describe '#initialize' do
+ it 'initializes with config and output' do
+ manager = described_class.new(config: default_config, output: output)
+ expect(manager).to be_a(described_class)
+ end
+
+ it 'is not managing an RPC server initially' do
+ manager = described_class.new(config: default_config, output: output)
+ expect(manager.rpc_managed?).to be false
+ end
+
+ it 'has no rpc_pid initially' do
+ manager = described_class.new(config: default_config, output: output)
+ expect(manager.rpc_pid).to be_nil
+ end
+ end
+
+ describe '#rpc_available?' do
+ let(:manager) { described_class.new(config: default_config, output: output) }
+
+ context 'when RPC server is listening' do
+ it 'returns true' do
+ tcp_socket = instance_double('Rex::Socket::Tcp')
+ allow(Rex::Socket::Tcp).to receive(:create).with(
+ 'PeerHost' => 'localhost',
+ 'PeerPort' => 55553
+ ).and_return(tcp_socket)
+ allow(tcp_socket).to receive(:close)
+
+ expect(manager.rpc_available?).to be true
+ end
+
+ it 'closes the probe connection' do
+ tcp_socket = instance_double('Rex::Socket::Tcp')
+ allow(Rex::Socket::Tcp).to receive(:create).with(
+ 'PeerHost' => 'localhost',
+ 'PeerPort' => 55553
+ ).and_return(tcp_socket)
+ expect(tcp_socket).to receive(:close)
+
+ manager.rpc_available?
+ end
+ end
+
+ context 'when RPC server is not listening' do
+ it 'returns false on connection refused' do
+ allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError)
+
+ expect(manager.rpc_available?).to be false
+ end
+
+ it 'returns false on host unreachable' do
+ allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError)
+
+ expect(manager.rpc_available?).to be false
+ end
+
+ it 'returns false on network unreachable' do
+ allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError)
+
+ expect(manager.rpc_available?).to be false
+ end
+
+ it 'returns false on socket error' do
+ allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError)
+
+ expect(manager.rpc_available?).to be false
+ end
+
+ it 'returns false on timeout' do
+ allow(Rex::Socket::Tcp).to receive(:create).and_raise(Rex::ConnectionError)
+
+ expect(manager.rpc_available?).to be false
+ end
+ end
+
+ context 'with custom host and port' do
+ let(:custom_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(host: '192.0.2.1', port: 9999)
+ config
+ end
+ let(:manager) { described_class.new(config: custom_config, output: output) }
+
+ it 'probes the configured host and port' do
+ expect(Rex::Socket::Tcp).to receive(:create).with(
+ 'PeerHost' => '192.0.2.1',
+ 'PeerPort' => 9999
+ ).and_raise(Rex::ConnectionError)
+
+ manager.rpc_available?
+ end
+ end
+ end
+
+ describe '#auto_start_enabled?' do
+ context 'when auto_start_rpc is true in config' do
+ it 'returns true' do
+ manager = described_class.new(config: default_config, output: output)
+ expect(manager.auto_start_enabled?).to be true
+ end
+ end
+
+ context 'when auto_start_rpc is false in config' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(auto_start_rpc: false)
+ manager = described_class.new(config: config, output: output)
+
+ expect(manager.auto_start_enabled?).to be false
+ end
+ end
+
+ context 'when host is not localhost' do
+ it 'returns false for remote host' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(host: '192.0.2.1')
+ manager = described_class.new(config: config, output: output)
+
+ expect(manager.auto_start_enabled?).to be false
+ end
+
+ it 'returns false for remote hostname' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(host: 'remote.example.com')
+ manager = described_class.new(config: config, output: output)
+
+ expect(manager.auto_start_enabled?).to be false
+ end
+ end
+
+ context 'when host is localhost variants' do
+ it 'returns true for localhost' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(host: 'localhost')
+ manager = described_class.new(config: config, output: output)
+
+ expect(manager.auto_start_enabled?).to be true
+ end
+
+ it 'returns true for 127.0.0.1' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(host: '127.0.0.1')
+ manager = described_class.new(config: config, output: output)
+
+ expect(manager.auto_start_enabled?).to be true
+ end
+
+ it 'returns true for ::1 (IPv6 loopback)' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(host: '::1')
+ manager = described_class.new(config: config, output: output)
+
+ expect(manager.auto_start_enabled?).to be true
+ end
+ end
+
+ context 'when api type is json-rpc' do
+ it 'returns false (JSON-RPC auto-start not supported)' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(type: 'json-rpc', token: 'tok123')
+ manager = described_class.new(config: config, output: output)
+
+ expect(manager.auto_start_enabled?).to be false
+ end
+ end
+ end
+
+ describe '#start_rpc_server' do
+ let(:manager) { described_class.new(config: default_config, output: output) }
+
+ context 'when msfrpcd is available' do
+ before do
+ allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true)
+ allow(Process).to receive(:spawn).and_return(67890)
+ end
+
+ it 'spawns msfrpcd' do
+ expect(Process).to receive(:spawn)
+ manager.start_rpc_server
+ end
+
+ it 'sets the rpc_pid' do
+ manager.start_rpc_server
+ expect(manager.rpc_pid).to eq(67890)
+ end
+
+ it 'marks the RPC server as managed' do
+ manager.start_rpc_server
+ expect(manager.rpc_managed?).to be true
+ end
+
+ it 'passes credentials via environment variables' do
+ expect(Process).to receive(:spawn).with(
+ hash_including('MSF_RPC_USER' => 'testuser', 'MSF_RPC_PASS' => 'testpass'),
+ anything,
+ any_args
+ ).and_return(67890)
+
+ manager.start_rpc_server
+ end
+
+ it 'does not pass credentials as command-line arguments' do
+ expect(Process).to receive(:spawn) do |_env, _path, *cmd_args|
+ flat = cmd_args.flatten
+ expect(flat).not_to include('testuser')
+ expect(flat).not_to include('testpass')
+ expect(flat).not_to include('-U')
+ expect(flat).not_to include('-P')
+ end.and_return(67890)
+
+ manager.start_rpc_server
+ end
+
+ it 'passes the configured host to msfrpcd' do
+ expect(Process).to receive(:spawn) do |_env, _path, *cmd_args|
+ expect(cmd_args.flatten).to include('-a', 'localhost')
+ end.and_return(67890)
+
+ manager.start_rpc_server
+ end
+
+ it 'passes the configured port to msfrpcd' do
+ expect(Process).to receive(:spawn) do |_env, _path, *cmd_args|
+ expect(cmd_args.flatten).to include('-p', '55553')
+ end.and_return(67890)
+
+ manager.start_rpc_server
+ end
+
+ it 'passes the foreground flag to msfrpcd' do
+ expect(Process).to receive(:spawn) do |_env, _path, *cmd_args|
+ expect(cmd_args.flatten).to include('-f')
+ end.and_return(67890)
+
+ manager.start_rpc_server
+ end
+
+ it 'outputs a status message with PID' do
+ manager.start_rpc_server
+ expect(output.string).to include('msfrpcd')
+ expect(output.string).to include('67890')
+ end
+ end
+
+ context 'when msfrpcd is not found' do
+ before do
+ allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(false)
+ end
+
+ it 'raises an RpcStartupError' do
+ expect { manager.start_rpc_server }.to raise_error(
+ Msf::MCP::Metasploit::RpcStartupError, /msfrpcd.*not found/i
+ )
+ end
+
+ it 'does not set rpc_pid' do
+ begin
+ manager.start_rpc_server
+ rescue Msf::MCP::Metasploit::RpcStartupError
+ # expected
+ end
+ expect(manager.rpc_pid).to be_nil
+ end
+ end
+
+ context 'when already managing an RPC server' do
+ before do
+ allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true)
+ allow(Process).to receive(:spawn).and_return(12345)
+ manager.start_rpc_server
+ end
+
+ it 'does not start a second RPC server' do
+ expect(Process).not_to receive(:spawn)
+ manager.start_rpc_server
+ end
+
+ it 'outputs a message that RPC is already managed' do
+ manager.start_rpc_server
+ expect(output.string).to include('already')
+ end
+ end
+ end
+
+ describe '#wait_for_rpc' do
+ let(:manager) { described_class.new(config: default_config, output: output) }
+
+ context 'when RPC becomes available immediately' do
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(true)
+ end
+
+ it 'returns true' do
+ expect(manager.wait_for_rpc(timeout: 10)).to be true
+ end
+
+ it 'outputs a success message' do
+ manager.wait_for_rpc(timeout: 10)
+ expect(output.string).to include('RPC server is ready')
+ end
+ end
+
+ context 'when RPC becomes available after retries' do
+ before do
+ call_count = 0
+ allow(manager).to receive(:rpc_available?) do
+ call_count += 1
+ call_count >= 3
+ end
+ # Stub sleep to avoid actual delays in tests
+ allow(manager).to receive(:sleep)
+ end
+
+ it 'returns true after retrying' do
+ expect(manager.wait_for_rpc(timeout: 30)).to be true
+ end
+
+ it 'outputs waiting messages' do
+ manager.wait_for_rpc(timeout: 30)
+ expect(output.string).to include('Waiting for RPC server')
+ end
+ end
+
+ context 'when RPC never becomes available' do
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(false)
+ allow(manager).to receive(:sleep)
+ # Make Time.now advance to simulate timeout
+ start_time = Time.now
+ call_count = 0
+ allow(Time).to receive(:now) do
+ call_count += 1
+ start_time + (call_count * 2)
+ end
+ end
+
+ it 'raises ConnectionError after timeout' do
+ expect { manager.wait_for_rpc(timeout: 5) }.to raise_error(
+ Msf::MCP::Metasploit::ConnectionError, /timed out/i
+ )
+ end
+ end
+
+ context 'when the managed RPC process dies during wait' do
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(false)
+ allow(manager).to receive(:sleep)
+ manager.instance_variable_set(:@rpc_pid, 99999)
+ manager.instance_variable_set(:@rpc_managed, true)
+
+ # Simulate process dying: waitpid returns the pid (non-blocking check)
+ allow(Process).to receive(:waitpid).with(99999, Process::WNOHANG).and_return(99999)
+ end
+
+ it 'raises RpcStartupError indicating the process exited' do
+ expect { manager.wait_for_rpc(timeout: 30) }.to raise_error(
+ Msf::MCP::Metasploit::RpcStartupError, /exited|died|crashed/i
+ )
+ end
+ end
+
+ context 'with default timeout' do
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(true)
+ end
+
+ it 'uses a reasonable default timeout' do
+ # Should not raise and use the default timeout
+ expect { manager.wait_for_rpc }.not_to raise_error
+ end
+ end
+ end
+
+ describe '#stop_rpc_server' do
+ let(:manager) { described_class.new(config: default_config, output: output) }
+
+ context 'when managing an RPC server' do
+ before do
+ manager.instance_variable_set(:@rpc_pid, 12345)
+ manager.instance_variable_set(:@rpc_managed, true)
+ end
+
+ it 'sends SIGTERM to the RPC process' do
+ allow(Process).to receive(:kill).with('TERM', 12345)
+ allow(Process).to receive(:waitpid).with(12345, anything).and_return(12345)
+
+ expect(Process).to receive(:kill).with('TERM', 12345)
+ manager.stop_rpc_server
+ end
+
+ it 'waits for the process to exit' do
+ allow(Process).to receive(:kill).with('TERM', 12345)
+ expect(Process).to receive(:waitpid).with(12345, anything).and_return(12345)
+
+ manager.stop_rpc_server
+ end
+
+ it 'clears the rpc_pid after stopping' do
+ allow(Process).to receive(:kill).with('TERM', 12345)
+ allow(Process).to receive(:waitpid).with(12345, anything).and_return(12345)
+
+ manager.stop_rpc_server
+ expect(manager.rpc_pid).to be_nil
+ end
+
+ it 'marks the RPC server as no longer managed' do
+ allow(Process).to receive(:kill).with('TERM', 12345)
+ allow(Process).to receive(:waitpid).with(12345, anything).and_return(12345)
+
+ manager.stop_rpc_server
+ expect(manager.rpc_managed?).to be false
+ end
+
+ it 'outputs a status message' do
+ allow(Process).to receive(:kill).with('TERM', 12345)
+ allow(Process).to receive(:waitpid).with(12345, anything).and_return(12345)
+
+ manager.stop_rpc_server
+ expect(output.string).to include('Stopping')
+ end
+
+ it 'handles process already dead (Errno::ESRCH)' do
+ allow(Process).to receive(:kill).with('TERM', 12345).and_raise(Errno::ESRCH)
+
+ expect { manager.stop_rpc_server }.not_to raise_error
+ expect(manager.rpc_managed?).to be false
+ expect(manager.rpc_pid).to be_nil
+ end
+
+ it 'handles permission error (Errno::EPERM)' do
+ allow(Process).to receive(:kill).with('TERM', 12345).and_raise(Errno::EPERM)
+
+ expect { manager.stop_rpc_server }.not_to raise_error
+ expect(output.string).to include('permission')
+ end
+
+ it 'sends SIGKILL if process does not exit after grace period' do
+ allow(manager).to receive(:sleep)
+ allow(Process).to receive(:waitpid).with(12345, Process::WNOHANG).and_return(nil, nil)
+ allow(Process).to receive(:waitpid).with(12345, 0).and_return(12345)
+
+ expect(Process).to receive(:kill).with('TERM', 12345).ordered
+ expect(Process).to receive(:kill).with('KILL', 12345).ordered
+
+ manager.stop_rpc_server
+ end
+ end
+
+ context 'when not managing an RPC server' do
+ it 'is a no-op' do
+ expect(Process).not_to receive(:kill)
+ manager.stop_rpc_server
+ end
+
+ it 'does not output any message' do
+ manager.stop_rpc_server
+ expect(output.string).to be_empty
+ end
+ end
+
+ context 'when rpc_pid is set but rpc_managed is false' do
+ before do
+ manager.instance_variable_set(:@rpc_pid, 12345)
+ manager.instance_variable_set(:@rpc_managed, false)
+ end
+
+ it 'does not attempt to kill the process' do
+ expect(Process).not_to receive(:kill)
+ manager.stop_rpc_server
+ end
+ end
+ end
+
+ describe '#rpc_managed?' do
+ let(:manager) { described_class.new(config: default_config, output: output) }
+
+ it 'returns false initially' do
+ expect(manager.rpc_managed?).to be false
+ end
+
+ it 'returns true after starting an RPC server' do
+ allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true)
+ allow(Process).to receive(:spawn).and_return(12345)
+ manager.start_rpc_server
+
+ expect(manager.rpc_managed?).to be true
+ end
+
+ it 'returns false after stopping the RPC server' do
+ manager.instance_variable_set(:@rpc_pid, 12345)
+ manager.instance_variable_set(:@rpc_managed, true)
+
+ allow(Process).to receive(:kill)
+ allow(Process).to receive(:waitpid).and_return(12345)
+
+ manager.stop_rpc_server
+ expect(manager.rpc_managed?).to be false
+ end
+ end
+
+ describe '#rpc_pid' do
+ let(:manager) { described_class.new(config: default_config, output: output) }
+
+ it 'returns nil initially' do
+ expect(manager.rpc_pid).to be_nil
+ end
+
+ it 'returns the PID after starting' do
+ allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true)
+ allow(Process).to receive(:spawn).and_return(54321)
+ manager.start_rpc_server
+
+ expect(manager.rpc_pid).to eq(54321)
+ end
+ end
+
+ describe 'logging' do
+ let(:log_file) { Tempfile.new('rpc_manager_log').tap(&:close).path }
+ let(:manager) { described_class.new(config: default_config, output: output) }
+
+ before do
+ if log_source_registered?(Msf::MCP::LOG_SOURCE)
+ deregister_log_source(Msf::MCP::LOG_SOURCE)
+ end
+ register_log_source(
+ Msf::MCP::LOG_SOURCE,
+ Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file),
+ Rex::Logging::LEV_3
+ )
+ end
+
+ after do
+ if log_source_registered?(Msf::MCP::LOG_SOURCE)
+ deregister_log_source(Msf::MCP::LOG_SOURCE)
+ end
+ File.delete(log_file) if File.exist?(log_file)
+ end
+
+ context 'when starting RPC server' do
+ before do
+ allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true)
+ allow(Process).to receive(:spawn).and_return(12345)
+ end
+
+ it 'logs the startup event' do
+ manager.start_rpc_server
+ expect(File.read(log_file)).to match(/Starting.*RPC/i)
+ end
+ end
+
+ context 'when stopping RPC server' do
+ before do
+ manager.instance_variable_set(:@rpc_pid, 12345)
+ manager.instance_variable_set(:@rpc_managed, true)
+ allow(Process).to receive(:kill)
+ allow(Process).to receive(:waitpid).and_return(12345)
+ end
+
+ it 'logs the shutdown event' do
+ manager.stop_rpc_server
+ expect(File.read(log_file)).to match(/Stopping.*RPC/i)
+ end
+ end
+
+ context 'when RPC availability check succeeds' do
+ it 'logs at DEBUG level' do
+ tcp_socket = instance_double('Rex::Socket::Tcp')
+ allow(Rex::Socket::Tcp).to receive(:create).and_return(tcp_socket)
+ allow(tcp_socket).to receive(:close)
+
+ manager.rpc_available?
+ expect(File.read(log_file)).to include('RPC server is available')
+ end
+ end
+ end
+
+ describe 'configuration edge cases' do
+ context 'when msf_api section is missing auto_start_rpc key' do
+ let(:config_without_auto_start) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].reject { |k, _| k == :auto_start_rpc }
+ config
+ end
+
+ it 'defaults to auto_start_enabled? returning true for localhost messagepack' do
+ manager = described_class.new(config: config_without_auto_start, output: output)
+ expect(manager.auto_start_enabled?).to be true
+ end
+ end
+
+ context 'with SSL disabled' do
+ let(:no_ssl_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(ssl: false)
+ config
+ end
+
+ it 'passes SSL setting when spawning msfrpcd' do
+ manager = described_class.new(config: no_ssl_config, output: output)
+ allow(File).to receive(:executable?).with(Msf::MCP::RpcManager::MSFRPCD_PATH).and_return(true)
+
+ expect(Process).to receive(:spawn) do |_env, _path, *cmd_args|
+ expect(cmd_args.flatten).to include('-S')
+ end.and_return(11111)
+
+ manager.start_rpc_server
+ end
+ end
+
+ context 'with custom port' do
+ let(:custom_port_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(port: 44444)
+ config
+ end
+
+ it 'uses the configured port for availability checks' do
+ manager = described_class.new(config: custom_port_config, output: output)
+
+ expect(Rex::Socket::Tcp).to receive(:create).with(
+ 'PeerHost' => 'localhost',
+ 'PeerPort' => 44444
+ ).and_raise(Rex::ConnectionError)
+ manager.rpc_available?
+ end
+ end
+ end
+
+ describe '#credentials_provided?' do
+ context 'when both user and password are present' do
+ it 'returns true' do
+ manager = described_class.new(config: default_config, output: output)
+ expect(manager.send(:credentials_provided?)).to be true
+ end
+ end
+
+ context 'when user is nil' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(user: nil)
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:credentials_provided?)).to be false
+ end
+ end
+
+ context 'when password is nil' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(password: nil)
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:credentials_provided?)).to be false
+ end
+ end
+
+ context 'when user is empty string' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(user: '')
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:credentials_provided?)).to be false
+ end
+ end
+
+ context 'when password is empty string' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(password: '')
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:credentials_provided?)).to be false
+ end
+ end
+
+ context 'when user is whitespace only' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(user: ' ')
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:credentials_provided?)).to be false
+ end
+ end
+
+ context 'when both are nil' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(user: nil, password: nil)
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:credentials_provided?)).to be false
+ end
+ end
+ end
+
+ describe '#token_provided?' do
+ context 'when token is present' do
+ it 'returns true' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(token: 'valid_token')
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:token_provided?)).to be true
+ end
+ end
+
+ context 'when token is nil' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(token: nil)
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:token_provided?)).to be false
+ end
+ end
+
+ context 'when token is empty string' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(token: '')
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:token_provided?)).to be false
+ end
+ end
+
+ context 'when token is whitespace only' do
+ it 'returns false' do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(token: ' ')
+ manager = described_class.new(config: config, output: output)
+ expect(manager.send(:token_provided?)).to be false
+ end
+ end
+
+ context 'when token key is absent' do
+ it 'returns false' do
+ manager = described_class.new(config: default_config, output: output)
+ expect(manager.send(:token_provided?)).to be false
+ end
+ end
+ end
+
+ describe '#generate_random_credentials' do
+ let(:config) do
+ c = default_config.dup
+ c[:msf_api] = c[:msf_api].merge(user: nil, password: nil)
+ c
+ end
+ let(:manager) { described_class.new(config: config, output: output) }
+
+ it 'sets a random hex user in the config' do
+ manager.send(:generate_random_credentials)
+ expect(config[:msf_api][:user]).to match(/\A[0-9a-f]{16}\z/)
+ end
+
+ it 'sets a random hex password in the config' do
+ manager.send(:generate_random_credentials)
+ expect(config[:msf_api][:password]).to match(/\A[0-9a-f]{32}\z/)
+ end
+
+ it 'generates different credentials on each call' do
+ manager.send(:generate_random_credentials)
+ first_user = config[:msf_api][:user]
+ first_password = config[:msf_api][:password]
+
+ manager.send(:generate_random_credentials)
+ expect(config[:msf_api][:user]).not_to eq(first_user)
+ expect(config[:msf_api][:password]).not_to eq(first_password)
+ end
+
+ it 'outputs a message about generated credentials' do
+ manager.send(:generate_random_credentials)
+ expect(output.string).to include('Generated random credentials')
+ end
+
+ it 'logs the event via Rex' do
+ log_file = Tempfile.new('creds_log').tap(&:close).path
+ if log_source_registered?(Msf::MCP::LOG_SOURCE)
+ deregister_log_source(Msf::MCP::LOG_SOURCE)
+ end
+ register_log_source(
+ Msf::MCP::LOG_SOURCE,
+ Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file),
+ Rex::Logging::LEV_3
+ )
+
+ manager.send(:generate_random_credentials)
+
+ content = File.read(log_file)
+ expect(content).to match(/Generated random credentials/i)
+
+ deregister_log_source(Msf::MCP::LOG_SOURCE)
+ File.delete(log_file)
+ end
+ end
+
+ describe 'ensure_rpc_available' do
+ let(:manager) { described_class.new(config: default_config, output: output) }
+
+ context 'when RPC is already available' do
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(true)
+ end
+
+ it 'does not start a new RPC server' do
+ expect(manager).not_to receive(:start_rpc_server)
+ manager.ensure_rpc_available
+ end
+
+ it 'outputs that RPC is already running' do
+ manager.ensure_rpc_available
+ expect(output.string).to include('already running')
+ end
+ end
+
+ context 'when RPC is already available but no credentials provided' do
+ let(:no_creds_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(user: nil, password: nil)
+ config
+ end
+ let(:manager) { described_class.new(config: no_creds_config, output: output) }
+
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(true)
+ end
+
+ it 'raises RpcStartupError' do
+ expect { manager.ensure_rpc_available }.to raise_error(
+ Msf::MCP::Metasploit::RpcStartupError, /already running.*no credentials/i
+ )
+ end
+ end
+
+ context 'when RPC is already available but credentials are empty strings' do
+ let(:empty_creds_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(user: '', password: '')
+ config
+ end
+ let(:manager) { described_class.new(config: empty_creds_config, output: output) }
+
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(true)
+ end
+
+ it 'raises RpcStartupError' do
+ expect { manager.ensure_rpc_available }.to raise_error(
+ Msf::MCP::Metasploit::RpcStartupError, /already running.*no credentials/i
+ )
+ end
+ end
+
+ context 'when RPC is already available with JSON-RPC type and token provided' do
+ let(:jsonrpc_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(type: 'json-rpc', token: 'valid_token')
+ config
+ end
+ let(:manager) { described_class.new(config: jsonrpc_config, output: output) }
+
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(true)
+ end
+
+ it 'does not raise' do
+ expect { manager.ensure_rpc_available }.not_to raise_error
+ end
+ end
+
+ context 'when RPC is already available with JSON-RPC type but no token' do
+ let(:jsonrpc_no_token_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(type: 'json-rpc', token: nil)
+ config
+ end
+ let(:manager) { described_class.new(config: jsonrpc_no_token_config, output: output) }
+
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(true)
+ end
+
+ it 'raises RpcStartupError about missing token' do
+ expect { manager.ensure_rpc_available }.to raise_error(
+ Msf::MCP::Metasploit::RpcStartupError, /already running.*no token/i
+ )
+ end
+ end
+
+ context 'when RPC is not available with JSON-RPC type' do
+ let(:jsonrpc_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(type: 'json-rpc', token: 'valid_token')
+ config
+ end
+ let(:manager) { described_class.new(config: jsonrpc_config, output: output) }
+
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(false)
+ end
+
+ it 'raises RpcStartupError about auto-start not supported' do
+ expect { manager.ensure_rpc_available }.to raise_error(
+ Msf::MCP::Metasploit::RpcStartupError, /auto-start is not supported for JSON-RPC/i
+ )
+ end
+ end
+
+ context 'when RPC is not available and auto-start is enabled' do
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(false, true)
+ allow(manager).to receive(:auto_start_enabled?).and_return(true)
+ allow(manager).to receive(:start_rpc_server)
+ allow(manager).to receive(:wait_for_rpc).and_return(true)
+ end
+
+ it 'starts the RPC server' do
+ expect(manager).to receive(:start_rpc_server)
+ manager.ensure_rpc_available
+ end
+
+ it 'waits for the RPC server to become available' do
+ expect(manager).to receive(:wait_for_rpc)
+ manager.ensure_rpc_available
+ end
+ end
+
+ context 'when RPC is not available, auto-start enabled, and no credentials' do
+ let(:no_creds_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(user: nil, password: nil)
+ config
+ end
+ let(:manager) { described_class.new(config: no_creds_config, output: output) }
+
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(false)
+ allow(manager).to receive(:auto_start_enabled?).and_return(true)
+ allow(manager).to receive(:start_rpc_server)
+ allow(manager).to receive(:wait_for_rpc).and_return(true)
+ end
+
+ it 'generates random credentials' do
+ manager.ensure_rpc_available
+ expect(no_creds_config[:msf_api][:user]).not_to be_nil
+ expect(no_creds_config[:msf_api][:user]).not_to be_empty
+ expect(no_creds_config[:msf_api][:password]).not_to be_nil
+ expect(no_creds_config[:msf_api][:password]).not_to be_empty
+ end
+
+ it 'outputs a message about generated credentials' do
+ manager.ensure_rpc_available
+ expect(output.string).to include('Generated random credentials')
+ end
+
+ it 'generates a 16-character hex username' do
+ manager.ensure_rpc_available
+ expect(no_creds_config[:msf_api][:user]).to match(/\A[0-9a-f]{16}\z/)
+ end
+
+ it 'generates a 32-character hex password' do
+ manager.ensure_rpc_available
+ expect(no_creds_config[:msf_api][:password]).to match(/\A[0-9a-f]{32}\z/)
+ end
+
+ it 'still starts the RPC server after generating credentials' do
+ expect(manager).to receive(:start_rpc_server)
+ manager.ensure_rpc_available
+ end
+ end
+
+ context 'when RPC is not available and auto-start is disabled' do
+ let(:disabled_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(auto_start_rpc: false)
+ config
+ end
+ let(:manager) { described_class.new(config: disabled_config, output: output) }
+
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(false)
+ end
+
+ it 'does not start an RPC server' do
+ expect(manager).not_to receive(:start_rpc_server)
+ expect { manager.ensure_rpc_available }.to raise_error(Msf::MCP::Metasploit::RpcStartupError)
+ end
+
+ it 'raises RpcStartupError about the unavailable RPC server' do
+ expect { manager.ensure_rpc_available }.to raise_error(
+ Msf::MCP::Metasploit::RpcStartupError, /not running.*auto-start is disabled/i
+ )
+ end
+ end
+
+ context 'when RPC is not available and host is remote' do
+ let(:remote_config) do
+ config = default_config.dup
+ config[:msf_api] = config[:msf_api].merge(host: '192.0.2.1')
+ config
+ end
+ let(:manager) { described_class.new(config: remote_config, output: output) }
+
+ before do
+ allow(manager).to receive(:rpc_available?).and_return(false)
+ end
+
+ it 'does not attempt to start the RPC server' do
+ expect(manager).not_to receive(:start_rpc_server)
+ expect { manager.ensure_rpc_available }.to raise_error(Msf::MCP::Metasploit::RpcStartupError)
+ end
+
+ it 'raises RpcStartupError about the remote host' do
+ expect { manager.ensure_rpc_available }.to raise_error(
+ Msf::MCP::Metasploit::RpcStartupError, /not available.*192\.0\.2\.1/
+ )
+ end
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/security/input_validator_spec.rb b/spec/lib/msf/core/mcp/security/input_validator_spec.rb
new file mode 100644
index 0000000000000..b75ed1ba2707b
--- /dev/null
+++ b/spec/lib/msf/core/mcp/security/input_validator_spec.rb
@@ -0,0 +1,698 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Security::InputValidator do
+ describe '.validate_parameter!' do
+ context 'with Array constraint (enum)' do
+ it 'accepts value in the list' do
+ expect(described_class.validate_parameter!('color', 'red', %w[red green blue])).to be true
+ end
+
+ it 'rejects value not in the list' do
+ expect {
+ described_class.validate_parameter!('color', 'yellow', %w[red green blue])
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid color: "yellow". Must be one of: red, green, blue')
+ end
+
+ it 'includes the parameter name in error' do
+ expect {
+ described_class.validate_parameter!('fruit', 'pear', %w[apple banana])
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid fruit: "pear". Must be one of: apple, banana')
+ end
+ end
+
+ context 'with Range constraint' do
+ it 'accepts value within range' do
+ expect(described_class.validate_parameter!('port', 80, 1..65535)).to be true
+ end
+
+ it 'accepts boundary values' do
+ expect(described_class.validate_parameter!('port', 1, 1..65535)).to be true
+ expect(described_class.validate_parameter!('port', 65535, 1..65535)).to be true
+ end
+
+ it 'rejects value outside range' do
+ expect {
+ described_class.validate_parameter!('port', 0, 1..65535)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'port must be between 1 and 65535: 0')
+ end
+
+ it 'rejects non-integer value' do
+ expect {
+ described_class.validate_parameter!('port', 'abc', 1..65535)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'port must be an integer: "abc"')
+ end
+
+ it 'rejects nil value' do
+ expect {
+ described_class.validate_parameter!('port', nil, 1..65535)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'port cannot be nil')
+ end
+
+ it 'raises ArgumentError for non-integer range' do
+ expect {
+ described_class.validate_parameter!('x', 1, 'a'..'z')
+ }.to raise_error(ArgumentError, 'Range constraint must be a range of integers, got String..String')
+ end
+
+ context 'with Range value (range-in-range)' do
+ it 'accepts range within constraint' do
+ expect(described_class.validate_parameter!('ports', 80..443, 1..65535)).to be true
+ end
+
+ it 'accepts range matching constraint bounds' do
+ expect(described_class.validate_parameter!('ports', 1..65535, 1..65535)).to be true
+ end
+
+ it 'rejects range starting below constraint' do
+ expect {
+ described_class.validate_parameter!('ports', 0..443, 1..65535)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'ports must be between 1 and 65535: 0..443')
+ end
+
+ it 'rejects range ending above constraint' do
+ expect {
+ described_class.validate_parameter!('ports', 80..70000, 1..65535)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'ports must be between 1 and 65535: 80..70000')
+ end
+
+ it 'rejects backwards range' do
+ expect {
+ described_class.validate_parameter!('ports', 443..80, 1..65535)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'ports must be between 1 and 65535: 443..80')
+ end
+
+ it 'rejects range with non-integer bounds' do
+ expect {
+ described_class.validate_parameter!('ports', 'a'..'z', 1..65535)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'ports must have integer bounds: "a".."z"')
+ end
+ end
+ end
+
+ context 'with Regexp constraint' do
+ it 'accepts matching value' do
+ expect(described_class.validate_parameter!('name', 'abc_123', /\A\w+\z/)).to be true
+ end
+
+ it 'rejects non-matching value' do
+ expect {
+ described_class.validate_parameter!('name', 'has spaces', /\A\w+\z/)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid name format: has spaces')
+ end
+
+ it 'does not raise error for an integer value when max_size is set' do
+ expect(described_class.validate_parameter!('name', 33, /\A\w+\z/, max_size: 10)).to be true
+ end
+ end
+
+ context 'with allow_nil option' do
+ it 'allows nil when allow_nil is true' do
+ expect(described_class.validate_parameter!('proto', nil, %w[tcp udp], allow_nil: true)).to be true
+ end
+
+ it 'allows empty string when allow_nil is true' do
+ expect(described_class.validate_parameter!('proto', '', %w[tcp udp], allow_nil: true)).to be true
+ end
+
+ it 'rejects nil when allow_nil is false' do
+ expect {
+ described_class.validate_parameter!('proto', nil, %w[tcp udp])
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'proto cannot be nil')
+ end
+
+ it 'still validates non-nil values when allow_nil is true' do
+ expect {
+ described_class.validate_parameter!('proto', 'icmp', %w[tcp udp], allow_nil: true)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid proto: "icmp". Must be one of: tcp, udp')
+ end
+ end
+
+ context 'with unsupported constraint type' do
+ it 'raises ArgumentError' do
+ expect {
+ described_class.validate_parameter!('x', 'y', 42)
+ }.to raise_error(ArgumentError, 'Unsupported constraint type: Integer')
+ end
+ end
+ end
+
+ describe '.validate_ip_address!' do
+ context 'with valid IP addresses' do
+ it 'accepts valid IPv4 address' do
+ expect(described_class.validate_ip_address!('192.168.1.1')).to be true
+ end
+
+ it 'accepts valid IPv6 address' do
+ expect(described_class.validate_ip_address!('2001:0db8:85a3:0000:0000:8a2e:0370:7334')).to be true
+ end
+
+ it 'accepts valid CIDR notation' do
+ expect(described_class.validate_ip_address!('192.168.1.0/24')).to be true
+ end
+
+ it 'accepts localhost' do
+ expect(described_class.validate_ip_address!('127.0.0.1')).to be true
+ end
+ end
+
+ context 'with invalid IP addresses' do
+ it 'rejects malformed IPv4' do
+ ['256.1.1.1', '192.168.1', '192.168.1.1.1', 'a.b.c.d'].each do |addr|
+ expect {
+ described_class.validate_ip_address!(addr)
+ }.to raise_error(Msf::MCP::Security::ValidationError, "Invalid IP address or CIDR: #{addr}")
+ end
+ end
+
+ it 'rejects out of range octets' do
+ expect {
+ described_class.validate_ip_address!('192.168.300.1')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid IP address or CIDR: 192.168.300.1')
+ end
+
+ it 'rejects invalid CIDR' do
+ ['192.168.1.0/33', '192.168.1.0/-1', '192.168.1.0/abc'].each do |addr|
+ expect {
+ described_class.validate_ip_address!(addr)
+ }.to raise_error(Msf::MCP::Security::ValidationError, "Invalid IP address or CIDR: #{addr}")
+ end
+ end
+
+ it 'rejects random strings' do
+ ['notanip', 'test.example.com', '192.168.one.two'].each do |addr|
+ expect {
+ described_class.validate_ip_address!(addr)
+ }.to raise_error(Msf::MCP::Security::ValidationError, "Invalid IP address or CIDR: #{addr}")
+ end
+ end
+ end
+
+ context 'with empty or nil values' do
+ it 'accepts nil' do
+ expect(described_class.validate_ip_address!(nil)).to be true
+ end
+
+ it 'accepts empty string' do
+ expect(described_class.validate_ip_address!('')).to be true
+ end
+ end
+ end
+
+ describe '.validate_port_range!' do
+ context 'with valid single ports' do
+ it 'accepts port 1' do
+ expect(described_class.validate_port_range!(1)).to be true
+ end
+
+ it 'accepts port 65535' do
+ expect(described_class.validate_port_range!(65535)).to be true
+ end
+
+ it 'accepts port 80' do
+ expect(described_class.validate_port_range!(80)).to be true
+ end
+ end
+
+ context 'with valid port ranges' do
+ it 'accepts string range' do
+ expect(described_class.validate_port_range!('1-1024')).to be true
+ end
+
+ it 'accepts range with max ports' do
+ expect(described_class.validate_port_range!('1-65535')).to be true
+ end
+ end
+
+ context 'with invalid ports' do
+ it 'rejects port 0' do
+ expect {
+ described_class.validate_port_range!(0)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be between 1 and 65535: 0')
+ end
+
+ it 'rejects port above 65535' do
+ expect {
+ described_class.validate_port_range!(65536)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be between 1 and 65535: 65536')
+ end
+
+ it 'rejects negative ports' do
+ expect {
+ described_class.validate_port_range!(-1)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be between 1 and 65535: -1')
+ end
+ end
+
+ context 'with invalid range formats' do
+ it 'rejects backwards range' do
+ expect {
+ described_class.validate_port_range!('100-50')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Port range must be between 1 and 65535: 100..50')
+ end
+
+ it 'rejects "1-"' do
+ expect {
+ described_class.validate_port_range!('1-')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be an integer: "1-"')
+ end
+
+ it 'rejects "-100"' do
+ expect {
+ described_class.validate_port_range!('-100')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Port must be between 1 and 65535: -100')
+ end
+
+ it 'rejects "abc-def"' do
+ expect {
+ described_class.validate_port_range!('abc-def')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Port range must have integer bounds: abc-def')
+ end
+ end
+
+ context 'with empty or nil values' do
+ it 'accepts nil' do
+ expect(described_class.validate_port_range!(nil)).to be true
+ end
+
+ it 'accepts empty string' do
+ expect(described_class.validate_port_range!('')).to be true
+ end
+ end
+ end
+
+ describe '.validate_only_up!' do
+ context 'with valid boolean values' do
+ it 'accepts true' do
+ expect(described_class.validate_only_up!(true)).to be true
+ end
+
+ it 'accepts false' do
+ expect(described_class.validate_only_up!(false)).to be true
+ end
+ end
+
+ context 'with invalid values' do
+ it 'rejects string "true"' do
+ expect {
+ described_class.validate_only_up!('true')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid only_up: "true". Must be one of: true, false')
+ end
+
+ it 'rejects string "false"' do
+ expect {
+ described_class.validate_only_up!('false')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid only_up: "false". Must be one of: true, false')
+ end
+
+ it 'rejects nil' do
+ expect {
+ described_class.validate_only_up!(nil)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'only_up cannot be nil')
+ end
+
+ it 'rejects integer 1' do
+ expect {
+ described_class.validate_only_up!(1)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid only_up: 1. Must be one of: true, false')
+ end
+
+ it 'rejects integer 0' do
+ expect {
+ described_class.validate_only_up!(0)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid only_up: 0. Must be one of: true, false')
+ end
+ end
+ end
+
+ describe '.validate_protocol!' do
+ context 'with valid protocols' do
+ it 'accepts tcp' do
+ expect(described_class.validate_protocol!('tcp')).to be true
+ end
+
+ it 'accepts udp' do
+ expect(described_class.validate_protocol!('udp')).to be true
+ end
+
+ it 'accepts TCP (uppercase)' do
+ expect(described_class.validate_protocol!('TCP')).to be true
+ end
+
+ it 'accepts UDP (uppercase)' do
+ expect(described_class.validate_protocol!('UDP')).to be true
+ end
+
+ it 'accepts nil' do
+ expect(described_class.validate_protocol!(nil)).to be true
+ end
+
+ it 'accepts empty string' do
+ expect(described_class.validate_protocol!('')).to be true
+ end
+ end
+
+ context 'with invalid protocols' do
+ it 'rejects icmp' do
+ expect {
+ described_class.validate_protocol!('icmp')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Protocol: "icmp". Must be one of: tcp, udp')
+ end
+
+ it 'rejects http' do
+ expect {
+ described_class.validate_protocol!('http')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Protocol: "http". Must be one of: tcp, udp')
+ end
+
+ it 'rejects random string' do
+ expect {
+ described_class.validate_protocol!('invalid')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Protocol: "invalid". Must be one of: tcp, udp')
+ end
+ end
+ end
+
+ describe '.validate_search_query!' do
+ context 'with valid search queries' do
+ it 'accepts normal search terms' do
+ expect(described_class.validate_search_query!('apache')).to be true
+ end
+
+ it 'accepts search with spaces' do
+ expect(described_class.validate_search_query!('apache http')).to be true
+ end
+
+ it 'accepts search with hyphens' do
+ expect(described_class.validate_search_query!('ms17-010')).to be true
+ end
+ end
+
+ context 'with invalid search queries' do
+ it 'rejects empty string' do
+ expect {
+ described_class.validate_search_query!('')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Search query cannot be empty')
+ end
+
+ it 'rejects nil' do
+ expect {
+ described_class.validate_search_query!(nil)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Search query cannot be nil')
+ end
+
+ it 'rejects very long queries' do
+ long_query = 'a' * 501
+ expect {
+ described_class.validate_search_query!(long_query)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Search query too long (max 500 characters)')
+ end
+
+ it 'rejects non printable characters' do
+ expect {
+ described_class.validate_search_query!("bad\x15\x10")
+ }.to raise_error(Msf::MCP::Security::ValidationError, /^Invalid Search query format:/)
+ end
+ end
+ end
+
+ describe '.validate_limit!' do
+ it 'accepts valid limit' do
+ expect(described_class.validate_limit!(100)).to be true
+ end
+
+ it 'accepts minimum limit' do
+ expect(described_class.validate_limit!(1)).to be true
+ end
+
+ it 'rejects zero' do
+ expect {
+ described_class.validate_limit!(0)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: 0')
+ end
+
+ it 'rejects negative number' do
+ expect {
+ described_class.validate_limit!(-10)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: -10')
+ end
+
+ it 'rejects excessive limit' do
+ expect {
+ described_class.validate_limit!(10001)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: 10001')
+ end
+
+ it 'accepts nil' do
+ expect(described_class.validate_limit!(nil)).to be true
+ end
+
+ it 'accepts empty string' do
+ expect(described_class.validate_limit!('')).to be true
+ end
+ end
+
+ describe '.validate_offset!' do
+ it 'accepts valid offset' do
+ expect(described_class.validate_offset!(100)).to be true
+ end
+
+ it 'accepts zero offset' do
+ expect(described_class.validate_offset!(0)).to be true
+ end
+
+ it 'rejects negative offset' do
+ expect {
+ described_class.validate_offset!(-10)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Offset must be between 0 and 1000: -10')
+ end
+
+ it 'rejects non-integer offset' do
+ expect {
+ described_class.validate_offset!('abc')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Offset must be an integer: "abc"')
+ end
+
+ it 'rejects excessive offset' do
+ expect {
+ described_class.validate_offset!(10001)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Offset must be between 0 and 1000: 10001')
+ end
+ end
+
+ describe '.validate_pagination!' do
+ context 'with valid pagination parameters' do
+ it 'accepts valid limit and offset' do
+ expect { described_class.validate_pagination!(100, 50) }.not_to raise_error
+ end
+
+ it 'accepts nil limit and offset' do
+ expect { described_class.validate_pagination!(nil, nil) }.not_to raise_error
+ end
+
+ it 'accepts valid limit with nil offset' do
+ expect { described_class.validate_pagination!(50, nil) }.not_to raise_error
+ end
+
+ it 'accepts nil limit with valid offset' do
+ expect { described_class.validate_pagination!(nil, 0) }.not_to raise_error
+ end
+ end
+
+ context 'with invalid pagination parameters' do
+ it 'rejects invalid limit' do
+ expect {
+ described_class.validate_pagination!(0, 10)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: 0')
+ end
+
+ it 'rejects invalid offset' do
+ expect {
+ described_class.validate_pagination!(10, -5)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Offset must be between 0 and 1000: -5')
+ end
+
+ it 'rejects both invalid parameters' do
+ expect {
+ described_class.validate_pagination!(0, -5)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Limit must be between 1 and 1000: 0')
+ end
+ end
+ end
+
+ describe '.validate_module_type!' do
+ context 'with valid module types' do
+ it 'accepts exploit' do
+ expect(described_class.validate_module_type!('exploit')).to be true
+ end
+
+ it 'accepts auxiliary' do
+ expect(described_class.validate_module_type!('auxiliary')).to be true
+ end
+
+ it 'accepts post' do
+ expect(described_class.validate_module_type!('post')).to be true
+ end
+
+ it 'accepts payload' do
+ expect(described_class.validate_module_type!('payload')).to be true
+ end
+
+ it 'accepts encoder' do
+ expect(described_class.validate_module_type!('encoder')).to be true
+ end
+
+ it 'accepts evasion' do
+ expect(described_class.validate_module_type!('evasion')).to be true
+ end
+
+ it 'accepts nop' do
+ expect(described_class.validate_module_type!('nop')).to be true
+ end
+ end
+
+ context 'with invalid module types' do
+ it 'rejects invalid type' do
+ expect {
+ described_class.validate_module_type!('invalid')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module type: "invalid". Must be one of: exploit, auxiliary, post, payload, encoder, evasion, nop')
+ end
+
+ it 'rejects uppercase type' do
+ expect {
+ described_class.validate_module_type!('EXPLOIT')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module type: "EXPLOIT". Must be one of: exploit, auxiliary, post, payload, encoder, evasion, nop')
+ end
+
+ it 'rejects empty string' do
+ expect {
+ described_class.validate_module_type!('')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Module type cannot be empty')
+ end
+
+ it 'rejects nil' do
+ expect {
+ described_class.validate_module_type!(nil)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Module type cannot be nil')
+ end
+
+ it 'rejects scanner (not a valid type)' do
+ expect {
+ described_class.validate_module_type!('scanner')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module type: "scanner". Must be one of: exploit, auxiliary, post, payload, encoder, evasion, nop')
+ end
+ end
+ end
+
+ describe '.validate_module_name!' do
+ context 'with valid module names' do
+ it 'accepts simple module name' do
+ expect(described_class.validate_module_name!('apache_exploit')).to be true
+ end
+
+ it 'accepts module name with path' do
+ expect(described_class.validate_module_name!('exploit/windows/smb/ms17_010_eternalblue')).to be true
+ end
+
+ it 'accepts module name with hyphens' do
+ expect(described_class.validate_module_name!('exploit/windows/ms17-010')).to be true
+ end
+
+ it 'accepts module name with underscores' do
+ expect(described_class.validate_module_name!('auxiliary/scanner/http/wordpress_scanner')).to be true
+ end
+
+ it 'accepts module name with numbers' do
+ expect(described_class.validate_module_name!('exploit/multi/http/struts2_rest_xstream')).to be true
+ end
+ end
+
+ context 'with invalid module names' do
+ it 'rejects empty string' do
+ expect {
+ described_class.validate_module_name!('')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Module name cannot be empty')
+ end
+
+ it 'rejects nil' do
+ expect {
+ described_class.validate_module_name!(nil)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Module name cannot be nil')
+ end
+
+ it 'rejects whitespace-only string' do
+ expect {
+ described_class.validate_module_name!(' ')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: ')
+ end
+
+ it 'rejects very long module names' do
+ long_name = 'a' * 501
+ expect {
+ described_class.validate_module_name!(long_name)
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Module name too long (max 500 characters)')
+ end
+
+ it 'rejects module name with spaces' do
+ expect {
+ described_class.validate_module_name!('exploit/windows/my exploit')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: exploit/windows/my exploit')
+ end
+
+ it 'rejects module name with special characters' do
+ expect {
+ described_class.validate_module_name!('exploit/windows/test@exploit')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: exploit/windows/test@exploit')
+ end
+
+ it 'rejects module name with dots' do
+ expect {
+ described_class.validate_module_name!('exploit/windows/../etc/passwd')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: exploit/windows/../etc/passwd')
+ end
+
+ it 'rejects module name with backslashes' do
+ expect {
+ described_class.validate_module_name!('exploit\\windows\\test')
+ }.to raise_error(Msf::MCP::Security::ValidationError, 'Invalid Module name format: exploit\windows\test')
+ end
+ end
+ end
+
+ describe 'fuzzing tests' do
+ it 'handles random IP-like strings' do
+ 1000.times do
+ random_ip = "#{rand(300)}.#{rand(300)}.#{rand(300)}.#{rand(300)}"
+ begin
+ described_class.validate_ip_address!(random_ip)
+ rescue Msf::MCP::Security::ValidationError
+ # Expected for invalid IPs
+ end
+ end
+ end
+
+ it 'handles random port numbers' do
+ 1000.times do
+ random_port = rand(-100..70000)
+ begin
+ described_class.validate_port_range!(random_port)
+ rescue Msf::MCP::Security::ValidationError
+ # Expected for invalid ports
+ end
+ end
+ end
+
+ it 'handles random strings with special characters' do
+ special_chars = ['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '[', ']', '{', '}', '|', '\\', '/', '<', '>']
+ 100.times do
+ random_string = Array.new(rand(1..50)) { special_chars.sample }.join
+ begin
+ described_class.validate_search_query!(random_string)
+ rescue Msf::MCP::Security::ValidationError
+ # Expected for invalid queries
+ end
+ end
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/security/rate_limiter_spec.rb b/spec/lib/msf/core/mcp/security/rate_limiter_spec.rb
new file mode 100644
index 0000000000000..1482c0e43438c
--- /dev/null
+++ b/spec/lib/msf/core/mcp/security/rate_limiter_spec.rb
@@ -0,0 +1,187 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Security::RateLimiter do
+ describe '#initialize' do
+ it 'sets requests_per_minute' do
+ limiter = described_class.new(requests_per_minute: 30)
+ expect(limiter.instance_variable_get(:@requests_per_minute)).to eq(30)
+ end
+
+ it 'sets burst_size to requests_per_minute by default' do
+ limiter = described_class.new(requests_per_minute: 30)
+ expect(limiter.instance_variable_get(:@burst_size)).to eq(30)
+ end
+
+ it 'allows custom burst_size' do
+ limiter = described_class.new(requests_per_minute: 30, burst_size: 50)
+ expect(limiter.instance_variable_get(:@burst_size)).to eq(50)
+ end
+
+ it 'initializes with full token bucket' do
+ limiter = described_class.new(requests_per_minute: 60)
+ expect(limiter.instance_variable_get(:@tokens)).to eq(60.0)
+ end
+ end
+
+ describe '#check_rate_limit!' do
+ let(:limiter) { described_class.new(requests_per_minute: 60) }
+
+ it 'allows request when tokens available' do
+ expect { limiter.check_rate_limit! }.not_to raise_error
+ end
+
+ it 'consumes one token per request' do
+ initial_tokens = limiter.instance_variable_get(:@tokens)
+ new_tokens = limiter.check_rate_limit!
+ expect(new_tokens).to be < initial_tokens
+ end
+
+ it 'allows multiple requests up to burst_size' do
+ limiter = described_class.new(requests_per_minute: 10)
+
+ 10.times do
+ expect { limiter.check_rate_limit! }.not_to raise_error
+ end
+ end
+
+ it 'raises RateLimitExceededError when tokens exhausted' do
+ limiter = described_class.new(requests_per_minute: 2)
+
+ # Consume all tokens
+ 2.times { limiter.check_rate_limit! }
+
+ # Next request should raise
+ expect { limiter.check_rate_limit! }.to raise_error(
+ Msf::MCP::Security::RateLimitExceededError
+ )
+ end
+
+ it 'includes retry_after in error' do
+ limiter = described_class.new(requests_per_minute: 60)
+
+ # Exhaust tokens
+ 60.times { limiter.check_rate_limit! }
+
+ expect { limiter.check_rate_limit! }.to raise_error(Msf::MCP::Security::RateLimitExceededError) do |error|
+ expect(error.retry_after).to be_a(Integer)
+ expect(error.retry_after).to be > 0
+ end
+ end
+
+ it 'returns true when successful' do
+ tokens = limiter.check_rate_limit!
+ expect(tokens).not_to be_nil
+ end
+
+ it 'accepts optional tool_name parameter' do
+ expect { limiter.check_rate_limit!('test_tool') }.not_to raise_error
+ end
+ end
+
+ describe 'token refill' do
+ it 'adds tokens based on elapsed time' do
+ start = Time.now
+ allow(Time).to receive(:now).and_return(start)
+ limiter = described_class.new(requests_per_minute: 60)
+
+ # Consume all tokens
+ 60.times { limiter.check_rate_limit! }
+
+ # Advance time by 2 seconds (should refill ~2 tokens at 1/sec)
+ allow(Time).to receive(:now).and_return(start + 2)
+
+ # Should allow a request now
+ expect { limiter.check_rate_limit! }.not_to raise_error
+ end
+
+ it 'caps tokens at burst_size' do
+ start = Time.now
+ allow(Time).to receive(:now).and_return(start)
+ limiter = described_class.new(requests_per_minute: 60, burst_size: 5)
+
+ # Consume 1 token
+ limiter.check_rate_limit!
+
+ # Advance time far enough to fully refill
+ allow(Time).to receive(:now).and_return(start + 600)
+
+ # Should allow burst_size requests but not burst_size + 1
+ 5.times { expect { limiter.check_rate_limit! }.not_to raise_error }
+ expect { limiter.check_rate_limit! }.to raise_error(Msf::MCP::Security::RateLimitExceededError)
+ end
+
+ it 'refills proportionally to time elapsed' do
+ start = Time.now
+ allow(Time).to receive(:now).and_return(start)
+ limiter = described_class.new(requests_per_minute: 60)
+
+ # Exhaust all tokens
+ 60.times { limiter.check_rate_limit! }
+
+ # Advance by exactly 1 second (should add exactly 1 token at 60/min = 1/sec)
+ allow(Time).to receive(:now).and_return(start + 1)
+
+ # Should allow exactly 1 request
+ expect { limiter.check_rate_limit! }.not_to raise_error
+ expect { limiter.check_rate_limit! }.to raise_error(Msf::MCP::Security::RateLimitExceededError)
+ end
+ end
+
+ describe 'thread safety' do
+ it 'handles concurrent requests correctly' do
+ limiter = described_class.new(requests_per_minute: 100)
+
+ # Try to make 100 requests concurrently
+ threads = 100.times.map do
+ Thread.new do
+ limiter.check_rate_limit!
+ rescue Msf::MCP::Security::RateLimitExceededError
+ # Some may be rate limited, that's ok
+ nil
+ end
+ end
+
+ threads.each(&:join)
+
+ # All tokens should be consumed
+ expect(limiter.instance_variable_get(:@tokens)).to be < 1.0
+ end
+
+ it 'does not allow more requests than burst_size concurrently' do
+ limiter = described_class.new(requests_per_minute: 10)
+
+ success_count = 0
+ mutex = Mutex.new
+ threads = 20.times.map do
+ Thread.new do
+ limiter.check_rate_limit!
+ mutex.synchronize { success_count += 1 }
+ rescue Msf::MCP::Security::RateLimitExceededError
+ # Expected for requests beyond burst_size
+ end
+ end
+
+ threads.each(&:join)
+
+ # Should have exactly 10 successful requests
+ expect(success_count).to eq(10)
+ end
+ end
+end
+
+RSpec.describe Msf::MCP::Security::RateLimitExceededError do
+ describe '#initialize' do
+ it 'sets retry_after' do
+ error = described_class.new(5)
+ expect(error.retry_after).to eq(5)
+ end
+
+ it 'sets error message' do
+ error = described_class.new(5)
+ expect(error.message).to include('Rate limit exceeded')
+ expect(error.message).to include('5 seconds')
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/server_spec.rb b/spec/lib/msf/core/mcp/server_spec.rb
new file mode 100644
index 0000000000000..7a61d866d7726
--- /dev/null
+++ b/spec/lib/msf/core/mcp/server_spec.rb
@@ -0,0 +1,509 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Server do
+ let(:valid_config) do
+ {
+ msf_api: {
+ type: 'messagepack',
+ host: 'localhost',
+ port: 55553,
+ endpoint: '/api/',
+ user: 'test_user',
+ password: 'test_password'
+ },
+ rate_limit: {
+ requests_per_minute: 60,
+ burst_size: 10
+ }
+ }
+ end
+
+ let(:mock_msf_client) do
+ instance_double(Msf::MCP::Metasploit::Client).tap do |client|
+ allow(client).to receive(:shutdown)
+ end
+ end
+
+ let(:rate_limiter) do
+ Msf::MCP::Security::RateLimiter.new(
+ requests_per_minute: valid_config.dig(:rate_limit, :requests_per_minute) || 60,
+ burst_size: valid_config.dig(:rate_limit, :burst_size)
+ )
+ end
+
+ let(:mock_mcp_server) do
+ instance_double(::MCP::Server).tap do |server|
+ allow(server).to receive(:transport=)
+ end
+ end
+
+ let(:mock_transport) do
+ instance_double(::MCP::Server::Transports::StdioTransport).tap do |transport|
+ allow(transport).to receive(:open)
+ end
+ end
+
+ describe '#initialize' do
+ it 'initializes with required dependencies' do
+ # Mock the transport to prevent the server to actually start listening
+ transport = instance_double(MCP::Server::Transports::StdioTransport)
+ allow(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(transport)
+ allow(transport).to receive(:open)
+
+ server = described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ mcp_server = server.start
+
+ expect(mcp_server.server_context[:msf_client]).to eq(mock_msf_client)
+ expect(mcp_server.server_context[:rate_limiter]).to eq(rate_limiter)
+ end
+
+ it 'creates MCP server with correct parameters' do
+ expect(::MCP::Server).to receive(:new).with(
+ hash_including(
+ name: 'msfmcp',
+ version: Msf::MCP::Application::VERSION,
+ tools: be_an(Array),
+ server_context: be_a(Hash)
+ )
+ ).and_return(mock_mcp_server)
+
+ described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ end
+
+ it 'registers all MCP tools' do
+ expect(::MCP::Server).to receive(:new).with(
+ hash_including(
+ tools: array_including(
+ Msf::MCP::Tools::SearchModules,
+ Msf::MCP::Tools::ModuleInfo,
+ Msf::MCP::Tools::HostInfo,
+ Msf::MCP::Tools::ServiceInfo,
+ Msf::MCP::Tools::VulnerabilityInfo,
+ Msf::MCP::Tools::NoteInfo,
+ Msf::MCP::Tools::CredentialInfo,
+ Msf::MCP::Tools::LootInfo
+ )
+ )
+ ).and_return(mock_mcp_server)
+
+ described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ end
+
+ it 'creates server context with msf_client and rate_limiter' do
+ expect(::MCP::Server).to receive(:new).with(
+ hash_including(
+ server_context: hash_including(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ )
+ ).and_return(mock_mcp_server)
+
+ described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ end
+
+ it 'does not include config hash in server_context' do
+ expect(::MCP::Server).to receive(:new).with(
+ hash_including(
+ server_context: hash_not_including(:config)
+ )
+ ).and_return(mock_mcp_server)
+
+ described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ end
+
+ it 'passes a configuration object with around_request and exception_reporter' do
+ expect(::MCP::Server).to receive(:new) do |args|
+ config = args[:configuration]
+ expect(config).to be_a(::MCP::Configuration)
+ expect(config.around_request).to be_a(Proc)
+ expect(config.exception_reporter).to be_a(Proc)
+ mock_mcp_server
+ end
+
+ described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ end
+ end
+
+ describe '#start' do
+ let(:server) do
+ allow(::MCP::Server).to receive(:new).and_return(mock_mcp_server)
+ described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ end
+
+ context 'with stdio transport' do
+ it 'creates stdio transport' do
+ expect(::MCP::Server::Transports::StdioTransport).to receive(:new).with(mock_mcp_server).and_return(mock_transport)
+
+ server.start(transport: :stdio)
+ end
+
+ it 'opens the transport' do
+ allow(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(mock_transport)
+
+ expect(mock_transport).to receive(:open)
+
+ server.start(transport: :stdio)
+ end
+
+ it 'defaults to stdio when no transport specified' do
+ expect(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(mock_transport)
+
+ server.start
+ end
+ end
+
+ context 'with http transport' do
+ let(:mock_http_transport) do
+ instance_double(::MCP::Server::Transports::StreamableHTTPTransport)
+ end
+ let(:puma_handler) { double('puma_handler') }
+ let(:rack_app) { double('rack_app') }
+
+ before do
+ # Stub #require to prevent actually loading rack and rack/handler/puma
+ allow(server).to receive(:require).with('rack').and_return(true)
+ allow(server).to receive(:require).with('rack/handler/puma').and_return(true)
+
+ stub_const('Rack::Handler::Puma', puma_handler)
+ stub_const('Rack::Builder', double('Rack::Builder'))
+
+ allow(::MCP::Server::Transports::StreamableHTTPTransport).to receive(:new).and_return(mock_http_transport)
+ allow(Rack::Builder).to receive(:new).and_return(rack_app)
+
+ allow(puma_handler).to receive(:run)
+ end
+
+ it 'creates http transport' do
+ expect(::MCP::Server::Transports::StreamableHTTPTransport).to receive(:new).with(mock_mcp_server)
+
+ server.start(transport: :http, port: 3000)
+ end
+
+ it 'starts Puma server via Rack handler' do
+ expect(puma_handler).to receive(:run).with(
+ anything, # Rack app
+ hash_including(
+ Port: 3000,
+ Host: 'localhost'
+ )
+ )
+
+ server.start(transport: :http, port: 3000)
+ end
+
+ it 'allows custom port' do
+ expect(puma_handler).to receive(:run).with(
+ anything,
+ hash_including(Port: 8080)
+ )
+
+ server.start(transport: :http, port: 8080)
+ end
+
+ it 'creates a Rack application' do
+ expect(puma_handler).to receive(:run) do |rack_app, options|
+ expect(rack_app).to be(rack_app)
+ expect(options).to include(Port: 3000, Host: 'localhost')
+ end
+
+ server.start(transport: :http, port: 3000)
+ end
+ end
+
+ context 'with invalid transport' do
+ it 'raises ArgumentError' do
+ expect {
+ server.start(transport: :websocket)
+ }.to raise_error(ArgumentError, /Unknown transport.*websocket/)
+ end
+
+ it 'error message mentions valid transports' do
+ expect {
+ server.start(transport: :invalid)
+ }.to raise_error(ArgumentError, /stdio.*http/)
+ end
+ end
+ end
+
+ describe '#shutdown' do
+ let(:server) do
+ allow(::MCP::Server).to receive(:new).and_return(mock_mcp_server)
+ described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ end
+
+ it 'shuts down the Metasploit client' do
+ expect(mock_msf_client).to receive(:shutdown)
+
+ server.shutdown
+ end
+
+ it 'handles nil msf_client gracefully' do
+ server.instance_variable_set(:@msf_client, nil)
+
+ expect { server.shutdown }.not_to raise_error
+ end
+
+ it 'clears mcp_server reference' do
+ server.shutdown
+
+ expect(server.instance_variable_get(:@mcp_server)).to be_nil
+ end
+
+ it 'can be called multiple times safely' do
+ expect {
+ server.shutdown
+ server.shutdown
+ }.not_to raise_error
+ end
+ end
+
+ describe 'dependency injection' do
+ let(:server) do
+ # Create server with pre-authenticated client
+ described_class.new(
+ msf_client: mock_msf_client,
+ rate_limiter: rate_limiter
+ )
+ end
+ let(:mcp_server) { server.start }
+
+ before do
+ # Mock the transport to prevent the server to actually start listening
+ transport = instance_double(MCP::Server::Transports::StdioTransport)
+ allow(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(transport)
+ allow(transport).to receive(:open)
+ end
+
+ it 'uses the provided authenticated client' do
+ # The provided client should be used
+ expect(mcp_server.server_context[:msf_client]).to eq(mock_msf_client)
+ expect(mcp_server.server_context[:msf_client].object_id).to eq(mock_msf_client.object_id)
+ end
+
+ it 'passes the provided client to server_context' do
+ expect(::MCP::Server).to receive(:new).with(
+ hash_including(
+ server_context: hash_including(
+ msf_client: mock_msf_client
+ )
+ )
+ ).and_return(mock_mcp_server)
+
+ server
+ end
+
+ context 'with a custom rate limiter' do
+ let(:rate_limiter) do
+ Msf::MCP::Security::RateLimiter.new(
+ requests_per_minute: 120,
+ burst_size: 20
+ )
+ end
+
+ it 'uses the provided rate_limiter' do
+ expect(mcp_server.server_context[:rate_limiter]).to eq(rate_limiter)
+ expect(mcp_server.server_context[:rate_limiter].instance_variable_get(:@requests_per_minute)).to eq(120)
+ expect(mcp_server.server_context[:rate_limiter].instance_variable_get(:@burst_size)).to eq(20)
+ end
+ end
+ end
+
+ # Instrumentation and logging tests
+ describe 'instrumentation and logging' do
+ require 'tempfile'
+ require 'json'
+
+ let(:log_file) { Tempfile.new(['test_log', '.log']).tap(&:close).path }
+
+ before do
+ if log_source_registered?(Msf::MCP::LOG_SOURCE)
+ deregister_log_source(Msf::MCP::LOG_SOURCE)
+ end
+ register_log_source(
+ Msf::MCP::LOG_SOURCE,
+ Msf::MCP::Logging::Sinks::JsonFlatfile.new(log_file),
+ Rex::Logging::LEV_3
+ )
+ # Mock the transport to prevent the server to actually start listening
+ transport = instance_double(MCP::Server::Transports::StdioTransport)
+ allow(::MCP::Server::Transports::StdioTransport).to receive(:new).and_return(transport)
+ allow(transport).to receive(:open)
+ end
+
+ after do
+ if log_source_registered?(Msf::MCP::LOG_SOURCE)
+ deregister_log_source(Msf::MCP::LOG_SOURCE)
+ end
+ File.delete(log_file) if File.exist?(log_file)
+ end
+
+ # Helper: parse the last JSON log entry from the file
+ def last_log_entry
+ JSON.parse(File.read(log_file).strip.split("\n").last)
+ end
+
+ let(:server) { described_class.new(msf_client: mock_msf_client, rate_limiter: rate_limiter) }
+ let(:mcp_server) { server.start }
+
+ describe 'around_request' do
+ it 'is always configured as a Proc' do
+ expect(mcp_server.configuration.around_request).to be_a(Proc)
+ end
+
+ it 'calls the request handler and returns its result' do
+ result = mcp_server.configuration.around_request.call({ tool_name: 'test' }) { { isError: false } }
+ expect(result).to eq({ isError: false })
+ end
+
+ it 'logs error tool calls with error severity' do
+ mcp_server.configuration.around_request.call(
+ { method: 'tools/call', tool_name: 'test_tool', error: 'tool_not_found' }
+ ) { nil }
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('ERROR')
+ expect(entry['message']).to include('MCP Error: tool_not_found')
+ end
+
+ it 'logs successful tool calls with info severity' do
+ mcp_server.configuration.around_request.call(
+ { method: 'tools/call', tool_name: 'search_modules' }
+ ) { { isError: false } }
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('INFO')
+ expect(entry['message']).to include('Tool call: search_modules')
+ end
+
+ it 'logs isError results with error severity' do
+ mcp_server.configuration.around_request.call(
+ { method: 'tools/call', tool_name: 'test_tool' }
+ ) { { isError: true } }
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('ERROR')
+ expect(entry['message']).to include('(ERROR)')
+ end
+
+ it 'logs via ilog when result is nil and no error' do
+ mcp_server.configuration.around_request.call(
+ { method: 'tools/call', tool_name: 'test_tool' }
+ ) { nil }
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('INFO')
+ expect(entry['message']).to include('Tool call: test_tool')
+ end
+
+ it 'logs prompt calls' do
+ mcp_server.configuration.around_request.call(
+ { method: 'prompts/get', prompt_name: 'exploit_suggestion' }
+ ) { {} }
+
+ expect(last_log_entry['message']).to include('Prompt call: exploit_suggestion')
+ end
+
+ it 'logs resource calls' do
+ mcp_server.configuration.around_request.call(
+ { method: 'resources/read', resource_uri: 'msf://exploits/windows' }
+ ) { {} }
+
+ expect(last_log_entry['message']).to include('Resource call: msf://exploits/windows')
+ end
+
+ it 'logs generic method calls' do
+ mcp_server.configuration.around_request.call({ method: 'ping' }) { {} }
+
+ expect(last_log_entry['message']).to include('Method call: ping')
+ end
+
+ it 'logs fallback message when no specific key is present' do
+ mcp_server.configuration.around_request.call({}) { {} }
+
+ expect(last_log_entry['message']).to include('MCP request')
+ end
+ end
+
+ describe 'exception_reporter' do
+ it 'is always configured as a Proc' do
+ expect(mcp_server.configuration.exception_reporter).to be_a(Proc)
+ end
+
+ it 'is a no-op when called with nil arguments' do
+ expect { mcp_server.configuration.exception_reporter.call(nil, nil) }.not_to raise_error
+ end
+
+ it 'logs exceptions with error severity' do
+ mcp_server.configuration.exception_reporter.call(
+ StandardError.new('Something went wrong'),
+ { request: '{"method":"tools/call","params":{"name":"msf_search_modules"}}' }
+ )
+ entry = last_log_entry
+
+ expect(entry['severity']).to eq('ERROR')
+ expect(entry['message']).to include('Error during request processing')
+ end
+
+ it 'extracts JSON-RPC method name from request context' do
+ mcp_server.configuration.exception_reporter.call(
+ StandardError.new('fail'),
+ { request: '{"method":"tools/call","params":{"name":"test"}}' }
+ )
+
+ expect(last_log_entry['message']).to include('(tools/call)')
+ end
+
+ it 'logs notification context' do
+ mcp_server.configuration.exception_reporter.call(
+ RuntimeError.new('Notification failed'),
+ { notification: 'notifications/initialized' }
+ )
+ entry = last_log_entry
+
+ expect(entry['message']).to include('Error during notification processing')
+ expect(entry['message']).to include('notifications/initialized')
+ end
+
+ it 'logs unknown context type' do
+ mcp_server.configuration.exception_reporter.call(StandardError.new('Unknown error'), {})
+
+ expect(last_log_entry['message']).to include('Error during unknown processing')
+ end
+
+ it 'handles a non-JSON request value' do
+ mcp_server.configuration.exception_reporter.call(
+ StandardError.new('Parse error'), { request: 'not valid json' }
+ )
+
+ expect(last_log_entry['message']).to include('Error during request processing')
+ end
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/credential_info_spec.rb b/spec/lib/msf/core/mcp/tools/credential_info_spec.rb
new file mode 100644
index 0000000000000..e4a229033f95a
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/credential_info_spec.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::CredentialInfo do
+ let(:msf_client) { double('Msf::MCP::Metasploit::Client') }
+ let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') }
+ let(:server_context) do
+ {
+ msf_client: msf_client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ let(:msf_response) do
+ {
+ 'creds' => [
+ {
+ 'user' => 'admin',
+ 'pass' => 'password123',
+ 'type' => 'password',
+ 'host' => '192.168.1.100',
+ 'sname' => 'smb',
+ 'port' => 445,
+ 'proto' => 'tcp',
+ 'updated_at' => 1640995200
+ },
+ {
+ 'user' => 'root',
+ 'pass' => 'toor',
+ 'type' => 'password',
+ 'host' => '192.168.1.101',
+ 'sname' => 'ssh',
+ 'port' => 22,
+ 'proto' => 'tcp',
+ 'updated_at' => 1609459300
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ allow(msf_client).to receive(:db_creds).and_return(msf_response)
+ end
+
+ describe 'Tool Name' do
+ it 'has the correct tool name from contract' do
+ expect(described_class.tool_name).to eq('msf_credential_info')
+ end
+ end
+
+ describe 'Input Schema Validation' do
+ it 'defines workspace as required parameter' do
+ input_schema = described_class.input_schema
+ expect(input_schema.schema[:required]).to include('workspace')
+ end
+
+ it 'supports pagination parameters' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:limit]).not_to be_nil
+ expect(properties[:offset]).not_to be_nil
+ end
+ end
+
+ describe 'Response Structure' do
+ it 'returns credentials with username, type, source' do
+ # Validate output_schema defines credential fields
+ data_items = described_class.output_schema.schema[:properties][:data][:items][:properties]
+
+ expect(data_items[:host]).to eq({ type: 'string' })
+ expect(data_items[:port]).to eq({ type: 'integer' })
+ expect(data_items[:protocol]).to eq({ type: 'string' })
+ expect(data_items[:service_name]).to eq({ type: 'string' })
+ expect(data_items[:user]).to eq({ type: 'string' })
+ expect(data_items[:type]).to eq({ type: 'string' })
+ expect(data_items[:updated_at]).to eq({ type: 'string' })
+ end
+ end
+
+ describe '.call' do
+ it 'checks rate limit' do
+ described_class.call(server_context: server_context)
+ expect(rate_limiter).to have_received(:check_rate_limit!).with('credential_info')
+ end
+
+ it 'calls Metasploit client with workspace' do
+ described_class.call(workspace: 'test_ws', server_context: server_context)
+ expect(msf_client).to have_received(:db_creds).with(hash_including(workspace: 'test_ws'))
+ end
+
+ it 'uses default workspace' do
+ described_class.call(server_context: server_context)
+ expect(msf_client).to have_received(:db_creds).with(hash_including(workspace: 'default'))
+ end
+
+ it 'returns MCP::Tool::Response' do
+ result = described_class.call(server_context: server_context)
+ expect(result).to be_a(MCP::Tool::Response)
+ end
+
+ it 'includes metadata in response' do
+ result = described_class.call(workspace: 'default', server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:workspace]).to eq('default')
+ expect(metadata[:query_time]).to be_a(Float)
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ expect(metadata[:limit]).to eq(100)
+ expect(metadata[:offset]).to eq(0)
+ end
+
+ it 'includes transformed data in response' do
+ result = described_class.call(server_context: server_context)
+
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:host]).to eq('192.168.1.100')
+ expect(data.first[:user]).to eq('admin')
+ expect(data.first[:type]).to eq('password')
+ expect(data.first[:port]).to eq(445)
+ expect(data.first[:protocol]).to eq('tcp')
+ expect(data.first[:service_name]).to eq('smb')
+ end
+
+ it 'handles pagination with limit' do
+ result = described_class.call(limit: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles pagination with offset' do
+ result = described_class.call(offset: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles empty results' do
+ allow(msf_client).to receive(:db_creds).and_return({ 'creds' => [] })
+
+ result = described_class.call(server_context: server_context)
+
+ expect(result.structured_content[:data]).to eq([])
+ expect(result.structured_content[:metadata][:total_items]).to eq(0)
+ expect(result.structured_content[:metadata][:returned_items]).to eq(0)
+ end
+
+ it 'returns error response for rate limit exceeded' do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60))
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+
+ it 'returns error response for API errors' do
+ allow(msf_client).to receive(:db_creds)
+ .and_raise(Msf::MCP::Metasploit::APIError, 'Database error')
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Metasploit API error/)
+ end
+
+ it 'returns error response for authentication errors' do
+ allow(msf_client).to receive(:db_creds).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Invalid token')
+ )
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/host_info_spec.rb b/spec/lib/msf/core/mcp/tools/host_info_spec.rb
new file mode 100644
index 0000000000000..343fc8f574841
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/host_info_spec.rb
@@ -0,0 +1,232 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::HostInfo do
+ let(:msf_client) { double('Msf::MCP::Metasploit::Client') }
+ let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') }
+ let(:server_context) do
+ {
+ msf_client: msf_client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ let(:msf_response) do
+ {
+ 'hosts' => [
+ {
+ 'address' => '192.168.1.100',
+ 'mac' => '00:11:22:33:44:55',
+ 'name' => 'testhost',
+ 'os_name' => 'Linux',
+ 'os_flavor' => 'Ubuntu',
+ 'state' => 'alive',
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ },
+ {
+ 'address' => '192.168.1.101',
+ 'mac' => '00:11:22:33:44:56',
+ 'name' => 'testhost2',
+ 'os_name' => 'Windows',
+ 'state' => 'alive',
+ 'created_at' => 1609459300
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ allow(msf_client).to receive(:db_hosts).and_return(msf_response)
+ end
+
+ describe 'Tool Name' do
+ it 'has the correct tool name' do
+ expect(described_class.tool_name).to eq('msf_host_info')
+ end
+ end
+
+ describe 'Input Schema Validation' do
+ it 'defines workspace as optional parameter with default' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:workspace][:type]).to eq('string')
+ expect(properties[:workspace][:default]).to eq('default')
+ end
+
+ it 'defines addresses as optional string parameter' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:addresses][:type]).to eq('string')
+ end
+
+ it 'defines only_up as optional boolean parameter' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:only_up][:type]).to eq('boolean')
+ end
+
+ it 'supports pagination with limit and offset' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:limit]).not_to be_nil
+ expect(properties[:offset]).not_to be_nil
+ end
+ end
+
+ describe 'Output Schema' do
+ it 'returns hosts with IP, OS, MAC, timestamps' do
+ data_items = described_class.output_schema.schema[:properties][:data][:items][:properties]
+
+ expect(data_items[:address]).to eq({ type: 'string' })
+ expect(data_items[:mac_address]).to eq({ type: 'string' })
+ expect(data_items[:hostname]).to eq({ type: 'string' })
+ expect(data_items[:os_name]).to eq({ type: 'string' })
+ expect(data_items[:os_flavor]).to eq({ type: 'string' })
+ expect(data_items[:created_at]).to eq({ type: 'string' })
+ expect(data_items[:updated_at]).to eq({ type: 'string' })
+ end
+
+ it 'includes workspace in metadata' do
+ metadata_properties = described_class.output_schema.schema[:properties][:metadata][:properties]
+ expect(metadata_properties[:workspace]).to eq({ type: 'string' })
+ expect(metadata_properties[:query_time]).to eq({ type: 'number' })
+ expect(metadata_properties[:total_items]).to eq({ type: 'integer' })
+ expect(metadata_properties[:returned_items]).to eq({ type: 'integer' })
+ end
+ end
+
+ describe '.call' do
+ it 'checks rate limit' do
+ described_class.call(server_context: server_context)
+ expect(rate_limiter).to have_received(:check_rate_limit!).with('host_info')
+ end
+
+ it 'calls Metasploit client with workspace' do
+ described_class.call(workspace: 'test_ws', server_context: server_context)
+ expect(msf_client).to have_received(:db_hosts).with(hash_including(workspace: 'test_ws'))
+ end
+
+ it 'uses default workspace' do
+ described_class.call(server_context: server_context)
+ expect(msf_client).to have_received(:db_hosts).with(hash_including(workspace: 'default'))
+ end
+
+ it 'returns MCP::Tool::Response' do
+ result = described_class.call(server_context: server_context)
+ expect(result).to be_a(MCP::Tool::Response)
+ end
+
+ it 'includes metadata in response' do
+ result = described_class.call(workspace: 'default', server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:workspace]).to eq('default')
+ expect(metadata[:query_time]).to be_a(Float)
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ expect(metadata[:limit]).to eq(100)
+ expect(metadata[:offset]).to eq(0)
+ end
+
+ it 'includes transformed data in response' do
+ result = described_class.call(server_context: server_context)
+
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:address]).to eq('192.168.1.100')
+ expect(data.first[:hostname]).to eq('testhost')
+ end
+
+ it 'handles pagination with limit' do
+ result = described_class.call(limit: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles pagination with offset' do
+ result = described_class.call(offset: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].first[:address]).to eq('192.168.1.101')
+ end
+
+ it 'passes addresses filter to MSF client' do
+ described_class.call(addresses: '192.168.1.0/24', server_context: server_context)
+ expect(msf_client).to have_received(:db_hosts).with(hash_including(addresses: '192.168.1.0/24'))
+ end
+
+ it 'passes only_up filter to MSF client' do
+ described_class.call(only_up: true, server_context: server_context)
+ expect(msf_client).to have_received(:db_hosts).with(hash_including(only_up: true))
+ end
+
+ it 'validates limit parameter' do
+ result = described_class.call(limit: 0, server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/limit/i)
+ end
+
+ it 'validates offset parameter' do
+ result = described_class.call(offset: -1, server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/offset/i)
+ end
+
+ it 'returns error response for authentication errors' do
+ allow(msf_client).to receive(:db_hosts).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Invalid token')
+ )
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+
+ it 'returns error response for API errors' do
+ allow(msf_client).to receive(:db_hosts).and_raise(
+ Msf::MCP::Metasploit::APIError.new('Server error')
+ )
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Metasploit API error/)
+ end
+
+ it 'returns error response for rate limit exceeded' do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60))
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+
+ it 'handles empty results' do
+ allow(msf_client).to receive(:db_hosts).and_return({ 'hosts' => [] })
+
+ result = described_class.call(server_context: server_context)
+
+ expect(result.structured_content[:metadata][:total_items]).to eq(0)
+ expect(result.structured_content[:metadata][:returned_items]).to eq(0)
+ expect(result.structured_content[:data]).to eq([])
+ end
+
+ it 'returns content array with text representation' do
+ result = described_class.call(server_context: server_context)
+
+ expect(result.content).to be_an(Array)
+ expect(result.content.first[:type]).to eq('text')
+ expect(result.content.first[:text]).to be_a(String)
+
+ parsed = JSON.parse(result.content.first[:text])
+ expect(parsed['metadata']).to be_a(Hash)
+ expect(parsed['data']).to be_an(Array)
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/loot_info_spec.rb b/spec/lib/msf/core/mcp/tools/loot_info_spec.rb
new file mode 100644
index 0000000000000..73032ec523e7b
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/loot_info_spec.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::LootInfo do
+ let(:msf_client) { double('Msf::MCP::Metasploit::Client') }
+ let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') }
+ let(:server_context) do
+ {
+ msf_client: msf_client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ let(:msf_response) do
+ {
+ 'loots' => [
+ {
+ 'id' => 1,
+ 'host' => '192.168.1.100',
+ 'service' => 'ssh',
+ 'ltype' => 'credentials',
+ 'path' => '/path/to/loot',
+ 'ctype' => 'text/plain',
+ 'name' => 'passwords.txt',
+ 'info' => 'Collected passwords',
+ 'data' => 'some data',
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ },
+ {
+ 'id' => 2,
+ 'host' => '192.168.1.101',
+ 'service' => 'http',
+ 'ltype' => 'file',
+ 'path' => '/path/to/file',
+ 'ctype' => 'application/octet-stream',
+ 'name' => 'upload.exe',
+ 'info' => 'Downloaded file',
+ 'data' => 'file data',
+ 'created_at' => 1609459300,
+ 'updated_at' => 1640995300
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ allow(msf_client).to receive(:db_loot).and_return(msf_response)
+ end
+
+ describe 'Tool Name' do
+ it 'has the correct tool name' do
+ expect(described_class.tool_name).to eq('msf_loot_info')
+ end
+ end
+
+ describe 'Input Schema Validation' do
+ it 'defines workspace as required parameter' do
+ input_schema = described_class.input_schema
+ expect(input_schema.schema[:required]).to include('workspace')
+ end
+
+ it 'supports pagination parameters' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:limit]).not_to be_nil
+ expect(properties[:offset]).not_to be_nil
+ end
+ end
+
+ describe 'Output Schema' do
+ it 'returns loot with type, name, content_type, size' do
+ data_items = described_class.output_schema.schema[:properties][:data][:items][:properties]
+
+ expect(data_items[:host]).to eq({ type: 'string' })
+ expect(data_items[:service_name_or_port]).to eq({ type: 'string' })
+ expect(data_items[:loot_type]).to eq({ type: 'string' })
+ expect(data_items[:content_type]).to eq({ type: 'string' })
+ expect(data_items[:name]).to eq({ type: 'string' })
+ expect(data_items[:info]).to eq({ type: 'string' })
+ expect(data_items[:data]).to eq({ type: 'string' })
+ expect(data_items[:created_at]).to eq({ type: 'string' })
+ expect(data_items[:updated_at]).to eq({ type: 'string' })
+ end
+ end
+
+ describe '.call' do
+ it 'checks rate limit' do
+ described_class.call(server_context: server_context)
+ expect(rate_limiter).to have_received(:check_rate_limit!).with('loot_info')
+ end
+
+ it 'calls Metasploit client with workspace' do
+ described_class.call(workspace: 'test_ws', server_context: server_context)
+ expect(msf_client).to have_received(:db_loot).with(hash_including(workspace: 'test_ws'))
+ end
+
+ it 'uses default workspace' do
+ described_class.call(server_context: server_context)
+ expect(msf_client).to have_received(:db_loot).with(hash_including(workspace: 'default'))
+ end
+
+ it 'returns MCP::Tool::Response' do
+ result = described_class.call(server_context: server_context)
+ expect(result).to be_a(MCP::Tool::Response)
+ end
+
+ it 'includes metadata in response' do
+ result = described_class.call(workspace: 'default', server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:workspace]).to eq('default')
+ expect(metadata[:query_time]).to be_a(Float)
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ expect(metadata[:limit]).to eq(100)
+ expect(metadata[:offset]).to eq(0)
+ end
+
+ it 'includes transformed data in response' do
+ result = described_class.call(server_context: server_context)
+
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:host]).to eq('192.168.1.100')
+ expect(data.first[:loot_type]).to eq('credentials')
+ expect(data.first[:content_type]).to eq('text/plain')
+ expect(data.first[:name]).to eq('passwords.txt')
+ end
+
+ it 'handles pagination with limit' do
+ result = described_class.call(limit: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles pagination with offset' do
+ result = described_class.call(offset: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles empty results' do
+ allow(msf_client).to receive(:db_loot).and_return({ 'loots' => [] })
+
+ result = described_class.call(server_context: server_context)
+
+ expect(result.structured_content[:data]).to eq([])
+ expect(result.structured_content[:metadata][:total_items]).to eq(0)
+ expect(result.structured_content[:metadata][:returned_items]).to eq(0)
+ end
+
+ it 'returns error response for rate limit exceeded' do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60))
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+
+ it 'returns error response for API errors' do
+ allow(msf_client).to receive(:db_loot)
+ .and_raise(Msf::MCP::Metasploit::APIError, 'Database error')
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Metasploit API error/)
+ end
+
+ it 'returns error response for authentication errors' do
+ allow(msf_client).to receive(:db_loot).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Invalid token')
+ )
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+
+ it 'passes pagination parameters to MSF client' do
+ # Pagination is applied client-side, not in the API call
+ described_class.call(limit: 50, offset: 10, server_context: server_context)
+
+ # API call only receives workspace parameter
+ expect(msf_client).to have_received(:db_loot).with(
+ hash_including(workspace: 'default')
+ )
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/module_info_spec.rb b/spec/lib/msf/core/mcp/tools/module_info_spec.rb
new file mode 100644
index 0000000000000..2eb13e596f4f9
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/module_info_spec.rb
@@ -0,0 +1,175 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::ModuleInfo do
+ let(:msf_client) { double('Msf::MCP::Metasploit::Client') }
+ let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') }
+ let(:server_context) do
+ {
+ msf_client: msf_client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ let(:msf_response) do
+ {
+ 'name' => 'ms17_010_eternalblue',
+ 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue',
+ 'type' => 'exploit',
+ 'rank' => 'excellent',
+ 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption',
+ 'license' => 'Metasploit Framework License (BSD)',
+ 'filepath' => '/usr/share/metasploit-framework/modules/exploits/windows/smb/ms17_010_eternalblue.rb',
+ 'arch' => ['x86', 'x64'],
+ 'platform' => ['Windows'],
+ 'authors' => ['sleepya', 'zerosum0x0'],
+ 'references' => [
+ ['CVE', '2017-0143'],
+ ['MSB', 'MS17-010']
+ ]
+ }
+ end
+
+ before do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ allow(msf_client).to receive(:module_info).and_return(msf_response)
+ end
+
+ describe 'Tool Name' do
+ it 'has the correct tool name' do
+ expect(described_class.tool_name).to eq('msf_module_info')
+ end
+ end
+
+ describe 'Input Schema Validation' do
+ it 'defines type and name as required parameters' do
+ input_schema = described_class.input_schema
+ expect(input_schema.schema[:required]).to include("type", "name")
+ end
+
+ it 'defines type as enum with valid module types' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:type][:type]).to eq('string')
+ expect(properties[:type][:enum]).to include('exploit', 'auxiliary', 'post', 'payload')
+ end
+
+ it 'defines name as string type' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:name][:type]).to eq('string')
+ end
+ end
+
+ describe 'Output Schema' do
+ it 'returns complete module details' do
+ output_schema = described_class.output_schema.schema
+ data_properties = output_schema[:properties][:data][:properties]
+
+ expect(data_properties[:type]).to eq({ type: 'string' })
+ expect(data_properties[:name]).to eq({ type: 'string' })
+ expect(data_properties[:fullname]).to eq({ type: 'string' })
+ expect(data_properties[:description]).to eq({ type: 'string' })
+ expect(data_properties[:rank]).to eq({ type: 'string' })
+ expect(data_properties[:authors]).to eq({ type: 'array', items: { type: 'string' } })
+ expect(data_properties[:platforms]).to eq({ type: 'array', items: { type: 'string' } })
+ expect(data_properties[:architectures]).to eq({ type: 'array', items: { type: 'string', enum: %w[
+ x86 x86_64 x64 mips mipsle mipsbe mips64 mips64le ppc ppce500v2
+ ppc64 ppc64le cbea cbea64 sparc sparc64 armle armbe aarch64 cmd
+ php tty java ruby dalvik python nodejs firefox zarch r
+ riscv32be riscv32le riscv64be riscv64le loongarch64
+ ] } })
+ end
+
+ it 'includes options object with configuration parameters' do
+ data_properties = described_class.output_schema.schema[:properties][:data][:properties]
+ expect(data_properties[:options]).to eq({ type: 'object' })
+ expect(data_properties[:default_options]).to eq({ type: 'object' })
+ end
+
+ it 'includes targets object for exploit modules' do
+ data_properties = described_class.output_schema.schema[:properties][:data][:properties]
+ expect(data_properties[:targets]).to eq({ type: 'object' })
+ expect(data_properties[:default_target]).to eq({ type: 'integer' })
+ end
+
+ it 'includes references array with CVE, MSB, URL refs' do
+ data_properties = described_class.output_schema.schema[:properties][:data][:properties]
+ expect(data_properties[:references]).to eq({ type: 'array', items: { type: ['string', 'object'] } })
+ end
+ end
+
+ describe '.call' do
+ it 'checks rate limit' do
+ described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context)
+ expect(rate_limiter).to have_received(:check_rate_limit!).with('module_info')
+ end
+
+ it 'calls Metasploit client with module type and name' do
+ described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context)
+ expect(msf_client).to have_received(:module_info).with('exploit', 'windows/smb/ms17_010_eternalblue')
+ end
+
+ it 'returns MCP::Tool::Response' do
+ result = described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context)
+ expect(result).to be_a(MCP::Tool::Response)
+ end
+
+ it 'includes metadata in response' do
+ result = described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:query_time]).to be_a(Float)
+ end
+
+ it 'includes transformed data in response' do
+ result = described_class.call(type: 'exploit', name: 'windows/smb/ms17_010_eternalblue', server_context: server_context)
+
+ data = result.structured_content[:data]
+ expect(data).to be_a(Hash)
+ expect(data[:fullname]).to eq('exploit/windows/smb/ms17_010_eternalblue')
+ expect(data[:type]).to eq('exploit')
+ end
+
+ it 'validates module type' do
+ result = described_class.call(type: 'invalid', name: 'test', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/type/i)
+ end
+
+ it 'validates module name' do
+ result = described_class.call(type: 'exploit', name: '', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/name/i)
+ end
+
+ it 'returns error response for authentication errors' do
+ allow(msf_client).to receive(:module_info).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Invalid token')
+ )
+
+ result = described_class.call(type: 'exploit', name: 'test', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+
+ it 'returns error response for API errors' do
+ allow(msf_client).to receive(:module_info).and_raise(
+ Msf::MCP::Metasploit::APIError.new('Server error')
+ )
+
+ result = described_class.call(type: 'exploit', name: 'test', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Metasploit API error/)
+ end
+
+ it 'returns error response for rate limit exceeded' do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60))
+
+ result = described_class.call(type: 'exploit', name: 'test', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/note_info_spec.rb b/spec/lib/msf/core/mcp/tools/note_info_spec.rb
new file mode 100644
index 0000000000000..0f1a4f576472b
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/note_info_spec.rb
@@ -0,0 +1,227 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::NoteInfo do
+ let(:msf_client) { double('Msf::MCP::Metasploit::Client') }
+ let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') }
+ let(:server_context) do
+ {
+ msf_client: msf_client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ let(:msf_response) do
+ {
+ 'notes' => [
+ {
+ 'id' => 1,
+ 'host' => '192.168.1.100',
+ 'service' => 'https',
+ 'port' => 443,
+ 'protocol' => 'tcp',
+ 'ntype' => 'ssl.certificate',
+ 'data' => {
+ 'cn' => 'example.com',
+ 'issuer' => 'Let\'s Encrypt',
+ 'expiration' => '2024-12-31'
+ },
+ 'critical' => false,
+ 'seen' => false,
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ },
+ {
+ 'id' => 2,
+ 'host' => '192.168.1.101',
+ 'service' => 'smb',
+ 'port' => 445,
+ 'protocol' => 'tcp',
+ 'ntype' => 'smb.fingerprint',
+ 'data' => {
+ 'os' => 'Windows Server 2019',
+ 'version' => '10.0'
+ },
+ 'critical' => false,
+ 'seen' => true,
+ 'created_at' => 1609459300
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ allow(msf_client).to receive(:db_notes).and_return(msf_response)
+ end
+
+ describe 'Tool Name' do
+ it 'has the correct tool name' do
+ expect(described_class.tool_name).to eq('msf_note_info')
+ end
+ end
+
+ describe 'Input Schema Validation' do
+ it 'defines workspace as required parameter' do
+ input_schema = described_class.input_schema
+ expect(input_schema.schema[:required]).to include('workspace')
+ end
+ end
+
+ describe 'Output Schema' do
+ it 'returns notes with type, content, timestamps' do
+ data_items = described_class.output_schema.schema[:properties][:data][:items][:properties]
+
+ expect(data_items[:host]).to eq({ type: 'string' })
+ expect(data_items[:service_name_or_port]).to eq({ type: 'string' })
+ expect(data_items[:note_type]).to eq({ type: 'string' })
+ expect(data_items[:data]).to eq({ type: 'string' })
+ expect(data_items[:created_at]).to eq({ type: 'string' })
+ end
+ end
+
+ describe '.call' do
+ it 'checks rate limit' do
+ described_class.call(server_context: server_context)
+ expect(rate_limiter).to have_received(:check_rate_limit!).with('note_info')
+ end
+
+ it 'calls Metasploit client with workspace' do
+ described_class.call(workspace: 'test_ws', server_context: server_context)
+ expect(msf_client).to have_received(:db_notes).with(hash_including(workspace: 'test_ws'))
+ end
+
+ it 'uses default workspace' do
+ described_class.call(server_context: server_context)
+ expect(msf_client).to have_received(:db_notes).with(hash_including(workspace: 'default'))
+ end
+
+ it 'returns MCP::Tool::Response' do
+ result = described_class.call(server_context: server_context)
+ expect(result).to be_a(MCP::Tool::Response)
+ end
+
+ it 'includes metadata in response' do
+ result = described_class.call(workspace: 'default', server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:workspace]).to eq('default')
+ expect(metadata[:query_time]).to be_a(Float)
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ expect(metadata[:limit]).to eq(100)
+ expect(metadata[:offset]).to eq(0)
+ end
+
+ it 'includes transformed data in response' do
+ result = described_class.call(server_context: server_context)
+
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:host]).to eq('192.168.1.100')
+ expect(data.first[:note_type]).to eq('ssl.certificate')
+ expect(data.first[:service_name_or_port]).to eq('https')
+ end
+
+ it 'handles pagination with limit' do
+ result = described_class.call(limit: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles pagination with offset' do
+ result = described_class.call(offset: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles filtering by type' do
+ described_class.call(type: 'ssl.certificate', server_context: server_context)
+
+ expect(msf_client).to have_received(:db_notes).with(
+ hash_including(ntype: 'ssl.certificate')
+ )
+ end
+
+ it 'handles filtering by host' do
+ described_class.call(host: '192.168.1.100', server_context: server_context)
+
+ expect(msf_client).to have_received(:db_notes).with(
+ hash_including(address: '192.168.1.100')
+ )
+ end
+
+ it 'handles filtering by ports' do
+ described_class.call(ports: '443', server_context: server_context)
+
+ expect(msf_client).to have_received(:db_notes).with(
+ hash_including(ports: '443')
+ )
+ end
+
+ it 'handles filtering by protocol' do
+ described_class.call(protocol: 'tcp', server_context: server_context)
+
+ expect(msf_client).to have_received(:db_notes).with(
+ hash_including(proto: 'tcp')
+ )
+ end
+
+ it 'handles empty results' do
+ allow(msf_client).to receive(:db_notes).and_return({ 'notes' => [] })
+
+ result = described_class.call(server_context: server_context)
+
+ expect(result.structured_content[:data]).to eq([])
+ expect(result.structured_content[:metadata][:total_items]).to eq(0)
+ expect(result.structured_content[:metadata][:returned_items]).to eq(0)
+ end
+
+ it 'returns error response for rate limit exceeded' do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60))
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+
+ it 'returns error response for API errors' do
+ allow(msf_client).to receive(:db_notes)
+ .and_raise(Msf::MCP::Metasploit::APIError, 'Database error')
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Metasploit API error/)
+ end
+
+ it 'returns error response for authentication errors' do
+ allow(msf_client).to receive(:db_notes).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Invalid token')
+ )
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+
+ it 'passes pagination parameters to MSF client' do
+ # Pagination is applied client-side, not in the API call
+ described_class.call(limit: 50, offset: 10, server_context: server_context)
+
+ # API call only receives workspace parameter
+ expect(msf_client).to have_received(:db_notes).with(
+ hash_including(workspace: 'default')
+ )
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/search_modules_spec.rb b/spec/lib/msf/core/mcp/tools/search_modules_spec.rb
new file mode 100644
index 0000000000000..cd59e9013a54f
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/search_modules_spec.rb
@@ -0,0 +1,250 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::SearchModules do
+ let(:msf_client) { double('Msf::MCP::Metasploit::Client') }
+ let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') }
+ let(:server_context) do
+ {
+ msf_client: msf_client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ let(:msf_response) do
+ [
+ {
+ 'name' => 'ms17_010_eternalblue',
+ 'fullname' => 'exploit/windows/smb/ms17_010_eternalblue',
+ 'type' => 'exploit',
+ 'rank' => 'excellent',
+ 'disclosuredate' => '2017-03-14',
+ 'description' => 'MS17-010 EternalBlue SMB Remote Windows Kernel Pool Corruption'
+ },
+ {
+ 'name' => 'smb_version',
+ 'fullname' => 'auxiliary/scanner/smb/smb_version',
+ 'type' => 'auxiliary',
+ 'rank' => 'normal',
+ 'description' => 'SMB Version Detection'
+ }
+ ]
+ end
+
+ before do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ allow(msf_client).to receive(:search_modules).and_return(msf_response)
+ end
+
+ describe 'Tool Name' do
+ it 'has the correct tool name' do
+ expect(described_class.tool_name).to eq('msf_search_modules')
+ end
+ end
+
+ describe 'Input Schema Validation' do
+ it 'defines query as required parameter' do
+ input_schema = described_class.input_schema
+ expect(input_schema.schema[:required]).to include("query")
+ end
+
+ it 'defines query as string type with length constraints' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:query][:type]).to eq('string')
+ expect(properties[:query][:minLength]).to eq(1)
+ expect(properties[:query][:maxLength]).to eq(500)
+ end
+
+ it 'defines limit as optional integer with constraints' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:limit][:type]).to eq('integer')
+ expect(properties[:limit][:minimum]).to eq(1)
+ expect(properties[:limit][:maximum]).to eq(1000)
+ expect(properties[:limit][:default]).to eq(100)
+ end
+
+ it 'defines offset as optional integer' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:offset][:type]).to eq('integer')
+ expect(properties[:offset][:minimum]).to eq(0)
+ expect(properties[:offset][:default]).to eq(0)
+ end
+ end
+
+ describe 'Output Schema' do
+ it 'returns response with metadata and data keys' do
+ output_schema = described_class.output_schema.schema
+ expect(output_schema[:required]).to include('metadata', 'data')
+ expect(output_schema[:properties][:metadata]).to be_a(Hash)
+ expect(output_schema[:properties][:data]).to be_a(Hash)
+ end
+
+ it 'metadata includes query, query_time, total_items, and pagination' do
+ properties = described_class.output_schema.schema[:properties][:metadata][:properties]
+ expect(properties[:query]).to eq({ type: 'string' })
+ expect(properties[:query_time]).to eq({ type: 'number' })
+ expect(properties[:total_items]).to eq({ type: 'integer' })
+ expect(properties[:returned_items]).to eq({ type: 'integer' })
+ expect(properties[:limit]).to eq({ type: 'integer' })
+ expect(properties[:offset]).to eq({ type: 'integer' })
+ end
+
+ it 'data array contains modules with required fields' do
+ data_schema = described_class.output_schema.schema[:properties][:data]
+ expect(data_schema[:type]).to eq('array')
+ expect(data_schema[:items]).to be_a(Hash)
+ expect(data_schema[:items][:properties]).to be_a(Hash)
+ end
+
+ it 'each module has fullname, type, and name as required fields' do
+ item_properties = described_class.output_schema.schema[:properties][:data][:items][:properties]
+ expect(item_properties[:fullname]).to eq({ type: 'string' })
+ expect(item_properties[:type]).to eq({ type: 'string' })
+ expect(item_properties[:name]).to eq({ type: 'string' })
+ expect(item_properties[:rank]).to eq({ type: 'string' })
+ expect(item_properties[:disclosure_date]).to eq({ type: 'string' })
+ end
+
+ it 'module type is one of the allowed enum values' do
+ item_properties = described_class.output_schema.schema[:properties][:data][:items][:properties]
+ expect(item_properties[:type][:type]).to eq('string')
+ end
+ end
+
+ describe '.call' do
+ it 'validates search query' do
+ result = described_class.call(query: '', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/query/i)
+ end
+
+ it 'checks rate limit' do
+ described_class.call(query: 'smb', server_context: server_context)
+ expect(rate_limiter).to have_received(:check_rate_limit!).with('search_modules')
+ end
+
+ it 'calls Metasploit client with query' do
+ described_class.call(query: 'smb windows', server_context: server_context)
+ expect(msf_client).to have_received(:search_modules).with('smb windows')
+ end
+
+ it 'returns MCP::Tool::Response' do
+ result = described_class.call(query: 'smb', server_context: server_context)
+ expect(result).to be_a(MCP::Tool::Response)
+ end
+
+ it 'includes metadata in response' do
+ result = described_class.call(query: 'smb', server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:query]).to eq('smb')
+ expect(metadata[:query_time]).to be_a(Float)
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ expect(metadata[:limit]).to eq(100)
+ expect(metadata[:offset]).to eq(0)
+ end
+
+ it 'includes transformed data in response' do
+ result = described_class.call(query: 'smb', server_context: server_context)
+
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:fullname]).to eq('exploit/windows/smb/ms17_010_eternalblue')
+ end
+
+ it 'handles pagination with limit' do
+ result = described_class.call(query: 'smb', limit: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles pagination with offset' do
+ result = described_class.call(query: 'smb', offset: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].first[:fullname]).to eq('auxiliary/scanner/smb/smb_version')
+ end
+
+ it 'validates limit parameter' do
+ result = described_class.call(query: 'smb', limit: 0, server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/limit/i)
+ end
+
+ it 'validates offset parameter' do
+ result = described_class.call(query: 'smb', offset: -1, server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/offset/i)
+ end
+
+ it 'returns error response for authentication errors' do
+ allow(msf_client).to receive(:search_modules).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Invalid token')
+ )
+
+ result = described_class.call(query: 'smb', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+
+ it 'returns error response for API errors' do
+ allow(msf_client).to receive(:search_modules).and_raise(
+ Msf::MCP::Metasploit::APIError.new('Server error')
+ )
+
+ result = described_class.call(query: 'smb', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Metasploit API error/)
+ end
+
+ it 'returns error response for validation errors' do
+ allow(rate_limiter).to receive(:check_rate_limit!).and_raise(
+ Msf::MCP::Security::ValidationError.new('Rate limit exceeded')
+ )
+
+ result = described_class.call(query: 'smb', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+
+ it 'returns error response for rate limit exceeded' do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60))
+
+ result = described_class.call(query: 'smb', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+
+ it 'handles empty search results' do
+ allow(msf_client).to receive(:search_modules).and_return([])
+
+ result = described_class.call(query: 'nonexistent', server_context: server_context)
+
+ expect(result.structured_content[:metadata][:total_items]).to eq(0)
+ expect(result.structured_content[:metadata][:returned_items]).to eq(0)
+ expect(result.structured_content[:data]).to eq([])
+ end
+
+ it 'returns content array with text representation' do
+ result = described_class.call(query: 'smb', server_context: server_context)
+
+ expect(result.content).to be_an(Array)
+ expect(result.content.first[:type]).to eq('text')
+ expect(result.content.first[:text]).to be_a(String)
+
+ parsed = JSON.parse(result.content.first[:text])
+ expect(parsed['metadata']).to be_a(Hash)
+ expect(parsed['data']).to be_an(Array)
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/service_info_spec.rb b/spec/lib/msf/core/mcp/tools/service_info_spec.rb
new file mode 100644
index 0000000000000..530a83c09348a
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/service_info_spec.rb
@@ -0,0 +1,241 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::ServiceInfo do
+ let(:msf_client) { double('Msf::MCP::Metasploit::Client') }
+ let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') }
+ let(:server_context) do
+ {
+ msf_client: msf_client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ let(:msf_response) do
+ {
+ 'services' => [
+ {
+ 'host' => '192.168.1.100',
+ 'port' => 80,
+ 'proto' => 'tcp',
+ 'state' => 'open',
+ 'name' => 'http',
+ 'info' => 'Apache httpd 2.4.41',
+ 'created_at' => 1609459200,
+ 'updated_at' => 1640995200
+ },
+ {
+ 'host' => '192.168.1.100',
+ 'port' => 443,
+ 'proto' => 'tcp',
+ 'state' => 'open',
+ 'name' => 'https',
+ 'info' => 'Apache httpd 2.4.41 (SSL)',
+ 'created_at' => 1609459300,
+ 'updated_at' => 1640995300
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ allow(msf_client).to receive(:db_services).and_return(msf_response)
+ end
+
+ describe 'Tool Name' do
+ it 'has the correct tool name' do
+ expect(described_class.tool_name).to eq('msf_service_info')
+ end
+ end
+
+ describe 'Input Schema Validation' do
+ it 'defines workspace as required parameter' do
+ input_schema = described_class.input_schema
+ expect(input_schema.schema[:required]).to include('workspace')
+ end
+
+ it 'supports multiple filter parameters' do
+ properties = described_class.input_schema.schema[:properties]
+ expect(properties[:host]).not_to be_nil
+ expect(properties[:ports]).not_to be_nil
+ expect(properties[:protocol]).not_to be_nil
+ expect(properties[:names]).not_to be_nil
+ expect(properties[:only_up]).not_to be_nil
+ end
+ end
+
+ describe 'Output Schema' do
+ it 'returns services with port, protocol, service_name' do
+ data_items = described_class.output_schema.schema[:properties][:data][:items][:properties]
+
+ expect(data_items[:port]).to eq({ type: 'integer' })
+ expect(data_items[:protocol]).to eq({ type: 'string' })
+ expect(data_items[:name]).to eq({ type: 'string' })
+ expect(data_items[:host_address]).to eq({ type: 'string' })
+ expect(data_items[:state]).to eq({ type: 'string' })
+ expect(data_items[:info]).to eq({ type: 'string' })
+ end
+ end
+
+ describe '.call' do
+ it 'checks rate limit' do
+ described_class.call(server_context: server_context)
+ expect(rate_limiter).to have_received(:check_rate_limit!).with('service_info')
+ end
+
+ it 'calls Metasploit client with workspace' do
+ described_class.call(workspace: 'test_ws', server_context: server_context)
+ expect(msf_client).to have_received(:db_services).with(hash_including(workspace: 'test_ws'))
+ end
+
+ it 'uses default workspace' do
+ described_class.call(server_context: server_context)
+ expect(msf_client).to have_received(:db_services).with(hash_including(workspace: 'default'))
+ end
+
+ it 'returns MCP::Tool::Response' do
+ result = described_class.call(server_context: server_context)
+ expect(result).to be_a(MCP::Tool::Response)
+ end
+
+ it 'includes metadata in response' do
+ result = described_class.call(workspace: 'default', server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:workspace]).to eq('default')
+ expect(metadata[:query_time]).to be_a(Float)
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ expect(metadata[:limit]).to eq(100)
+ expect(metadata[:offset]).to eq(0)
+ end
+
+ it 'includes transformed data in response' do
+ result = described_class.call(server_context: server_context)
+
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:host_address]).to eq('192.168.1.100')
+ expect(data.first[:port]).to eq(80)
+ expect(data.first[:protocol]).to eq('tcp')
+ expect(data.first[:name]).to eq('http')
+ expect(data.first[:state]).to eq('open')
+ end
+
+ it 'handles pagination with limit' do
+ result = described_class.call(limit: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles pagination with offset' do
+ result = described_class.call(offset: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].first[:port]).to eq(443)
+ end
+
+ it 'passes host filter to MSF client' do
+ described_class.call(host: '192.168.1.100', server_context: server_context)
+ expect(msf_client).to have_received(:db_services).with(hash_including(addresses: '192.168.1.100'))
+ end
+
+ it 'passes ports filter to MSF client' do
+ described_class.call(ports: '80', server_context: server_context)
+ expect(msf_client).to have_received(:db_services).with(hash_including(ports: '80'))
+ end
+
+ it 'passes protocol filter to MSF client' do
+ described_class.call(protocol: 'tcp', server_context: server_context)
+ expect(msf_client).to have_received(:db_services).with(hash_including(proto: 'tcp'))
+ end
+
+ it 'passes names filter to MSF client' do
+ described_class.call(names: 'http,https', server_context: server_context)
+ expect(msf_client).to have_received(:db_services).with(hash_including(names: 'http,https'))
+ end
+
+ it 'passes only_up filter to MSF client' do
+ described_class.call(only_up: true, server_context: server_context)
+ expect(msf_client).to have_received(:db_services).with(hash_including(only_up: true))
+ end
+
+ it 'validates limit parameter' do
+ result = described_class.call(limit: 0, server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/limit/i)
+ end
+
+ it 'validates offset parameter' do
+ result = described_class.call(offset: -1, server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/offset/i)
+ end
+
+ it 'validates protocol parameter' do
+ result = described_class.call(protocol: 'invalid', server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/protocol/i)
+ end
+
+ it 'handles empty results' do
+ allow(msf_client).to receive(:db_services).and_return({ 'services' => [] })
+
+ result = described_class.call(server_context: server_context)
+
+ expect(result.structured_content[:data]).to eq([])
+ expect(result.structured_content[:metadata][:total_items]).to eq(0)
+ expect(result.structured_content[:metadata][:returned_items]).to eq(0)
+ end
+
+ it 'returns error response for authentication errors' do
+ allow(msf_client).to receive(:db_services).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Invalid token')
+ )
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+
+ it 'returns error response for API errors' do
+ allow(msf_client).to receive(:db_services).and_raise(
+ Msf::MCP::Metasploit::APIError.new('Server error')
+ )
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Metasploit API error/)
+ end
+
+ it 'returns error response for rate limit exceeded' do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60))
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+
+ it 'returns content array with text representation' do
+ result = described_class.call(server_context: server_context)
+
+ expect(result.content).to be_an(Array)
+ expect(result.content.first[:type]).to eq('text')
+ expect(result.content.first[:text]).to be_a(String)
+
+ parsed = JSON.parse(result.content.first[:text])
+ expect(parsed['metadata']).to be_a(Hash)
+ expect(parsed['data']).to be_an(Array)
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb b/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb
new file mode 100644
index 0000000000000..5c118a4566e08
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/tool_helper_spec.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::ToolHelper do
+ # Create a test class that includes the helper inside class << self,
+ # mirroring how the actual tools use it.
+ let(:tool_class) do
+ mod = described_class
+ Class.new do
+ class << self
+ include Msf::MCP::Tools::ToolHelper
+ end
+ end
+ end
+
+ describe '#tool_error_response' do
+ it 'returns an MCP::Tool::Response' do
+ result = tool_class.tool_error_response('Something went wrong')
+ expect(result).to be_a(::MCP::Tool::Response)
+ end
+
+ it 'sets the error flag to true' do
+ result = tool_class.tool_error_response('Something went wrong')
+ expect(result.error?).to be true
+ end
+
+ it 'includes the error message in the content' do
+ result = tool_class.tool_error_response('Something went wrong')
+ expect(result.content).to eq([{ type: 'text', text: 'Something went wrong' }])
+ end
+
+ it 'preserves the full message for authentication errors' do
+ result = tool_class.tool_error_response('Authentication failed: Invalid token')
+ expect(result.content.first[:text]).to eq('Authentication failed: Invalid token')
+ expect(result.error?).to be true
+ end
+
+ it 'preserves the full message for API errors' do
+ result = tool_class.tool_error_response('Metasploit API error: Server error')
+ expect(result.content.first[:text]).to eq('Metasploit API error: Server error')
+ expect(result.error?).to be true
+ end
+
+ it 'preserves the full message for rate limit errors' do
+ result = tool_class.tool_error_response('Rate limit exceeded: Retry after 5 seconds.')
+ expect(result.content.first[:text]).to eq('Rate limit exceeded: Retry after 5 seconds.')
+ expect(result.error?).to be true
+ end
+ end
+end
diff --git a/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb b/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb
new file mode 100644
index 0000000000000..ec99f0aa9b1f5
--- /dev/null
+++ b/spec/lib/msf/core/mcp/tools/vulnerability_info_spec.rb
@@ -0,0 +1,224 @@
+# frozen_string_literal: true
+
+require 'msf/core/mcp'
+
+RSpec.describe Msf::MCP::Tools::VulnerabilityInfo do
+ let(:msf_client) { double('Msf::MCP::Metasploit::Client') }
+ let(:rate_limiter) { double('Msf::MCP::Security::RateLimiter') }
+ let(:server_context) do
+ {
+ msf_client: msf_client,
+ rate_limiter: rate_limiter,
+ config: {}
+ }
+ end
+
+ let(:msf_response) do
+ {
+ 'vulns' => [
+ {
+ 'host' => '192.168.1.100',
+ 'port' => 445,
+ 'proto' => 'tcp',
+ 'name' => 'MS17-010',
+ 'refs' => 'CVE-2017-0144,MS17-010',
+ 'time' => 1609459200
+ },
+ {
+ 'host' => '192.168.1.101',
+ 'port' => 80,
+ 'proto' => 'tcp',
+ 'name' => 'Apache Struts RCE',
+ 'refs' => 'CVE-2017-5638',
+ 'time' => 1609459300
+ }
+ ]
+ }
+ end
+
+ before do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ allow(msf_client).to receive(:db_vulns).and_return(msf_response)
+ end
+
+ describe 'Tool Name' do
+ it 'has the correct tool name' do
+ expect(described_class.tool_name).to eq('msf_vulnerability_info')
+ end
+ end
+
+ describe 'Input Schema Validation' do
+ it 'defines workspace as required parameter' do
+ input_schema = described_class.input_schema
+ expect(input_schema.schema[:required]).to include('workspace')
+ end
+ end
+
+ describe 'Output Schema' do
+ it 'returns vulnerabilities with CVE, severity, exploit_available' do
+ data_items = described_class.output_schema.schema[:properties][:data][:items][:properties]
+
+ expect(data_items[:host]).to eq({ type: 'string' })
+ expect(data_items[:port]).to eq({ type: 'integer' })
+ expect(data_items[:protocol]).to eq({ type: 'string' })
+ expect(data_items[:name]).to eq({ type: 'string' })
+ expect(data_items[:references]).to eq({ type: 'array', items: { type: 'string' } })
+ expect(data_items[:created_at]).to eq({ type: 'string' })
+ end
+ end
+
+ describe '.call' do
+ it 'checks rate limit' do
+ described_class.call(server_context: server_context)
+ expect(rate_limiter).to have_received(:check_rate_limit!).with('vulnerability_info')
+ end
+
+ it 'calls Metasploit client with workspace' do
+ described_class.call(workspace: 'test_ws', server_context: server_context)
+ expect(msf_client).to have_received(:db_vulns).with(hash_including(workspace: 'test_ws'))
+ end
+
+ it 'uses default workspace' do
+ described_class.call(server_context: server_context)
+ expect(msf_client).to have_received(:db_vulns).with(hash_including(workspace: 'default'))
+ end
+
+ it 'returns MCP::Tool::Response' do
+ result = described_class.call(server_context: server_context)
+ expect(result).to be_a(MCP::Tool::Response)
+ end
+
+ it 'includes metadata in response' do
+ result = described_class.call(workspace: 'default', server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:workspace]).to eq('default')
+ expect(metadata[:query_time]).to be_a(Float)
+ expect(metadata[:total_items]).to eq(2)
+ expect(metadata[:returned_items]).to eq(2)
+ expect(metadata[:limit]).to eq(100)
+ expect(metadata[:offset]).to eq(0)
+ end
+
+ it 'includes transformed data in response' do
+ result = described_class.call(server_context: server_context)
+
+ data = result.structured_content[:data]
+ expect(data).to be_an(Array)
+ expect(data.length).to eq(2)
+ expect(data.first[:host]).to eq('192.168.1.100')
+ expect(data.first[:name]).to eq('MS17-010')
+ expect(data.first[:port]).to eq(445)
+ expect(data.first[:protocol]).to eq('tcp')
+ end
+
+ it 'handles pagination with limit' do
+ result = described_class.call(limit: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:limit]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles pagination with offset' do
+ result = described_class.call(offset: 1, server_context: server_context)
+
+ metadata = result.structured_content[:metadata]
+ expect(metadata[:offset]).to eq(1)
+ expect(metadata[:returned_items]).to eq(1)
+ expect(result.structured_content[:data].length).to eq(1)
+ end
+
+ it 'handles filtering by names' do
+ described_class.call(names: ['MS17-010'], server_context: server_context)
+
+ # Names array is joined with commas before being sent to API
+ expect(msf_client).to have_received(:db_vulns).with(
+ hash_including(names: 'MS17-010')
+ )
+ end
+
+ it 'handles filtering by host' do
+ described_class.call(host: '192.168.1.100', server_context: server_context)
+
+ expect(msf_client).to have_received(:db_vulns).with(
+ hash_including(address: '192.168.1.100')
+ )
+ end
+
+ it 'handles filtering by ports' do
+ described_class.call(ports: '445', server_context: server_context)
+
+ expect(msf_client).to have_received(:db_vulns).with(
+ hash_including(ports: '445')
+ )
+ end
+
+ it 'handles filtering by protocol' do
+ described_class.call(protocol: 'tcp', server_context: server_context)
+
+ expect(msf_client).to have_received(:db_vulns).with(
+ hash_including(proto: 'tcp')
+ )
+ end
+
+ it 'handles empty results' do
+ allow(msf_client).to receive(:db_vulns).and_return({ 'vulns' => [] })
+
+ result = described_class.call(server_context: server_context)
+
+ expect(result.structured_content[:data]).to eq([])
+ expect(result.structured_content[:metadata][:total_items]).to eq(0)
+ expect(result.structured_content[:metadata][:returned_items]).to eq(0)
+ end
+
+ it 'returns error response for rate limit exceeded' do
+ allow(rate_limiter).to receive(:check_rate_limit!)
+ .and_raise(Msf::MCP::Security::RateLimitExceededError.new(60))
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Rate limit exceeded/)
+ end
+
+ it 'returns error response for API errors' do
+ allow(msf_client).to receive(:db_vulns)
+ .and_raise(Msf::MCP::Metasploit::APIError, 'Database error')
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Metasploit API error/)
+ end
+
+ it 'returns error response for authentication errors' do
+ allow(msf_client).to receive(:db_vulns).and_raise(
+ Msf::MCP::Metasploit::AuthenticationError.new('Invalid token')
+ )
+
+ result = described_class.call(server_context: server_context)
+ expect(result.error?).to be true
+ expect(result.content.first[:text]).to match(/Authentication failed/)
+ end
+
+ it 'passes pagination parameters to MSF client' do
+ # Pagination is applied client-side, not in the API call
+ described_class.call(limit: 50, offset: 10, server_context: server_context)
+
+ # API call only receives workspace parameter
+ expect(msf_client).to have_received(:db_vulns).with(
+ hash_including(workspace: 'default')
+ )
+ end
+
+ it 'handles multiple name filters' do
+ names = ['MS17-010', 'Apache Struts RCE']
+ described_class.call(names: names, server_context: server_context)
+
+ # Names array is joined with commas before being sent to API
+ expect(msf_client).to have_received(:db_vulns).with(
+ hash_including(names: 'MS17-010,Apache Struts RCE')
+ )
+ end
+ end
+end