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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ Gemfile.local.lock
.solargraph.yml
# database config for testing
config/database.yml
# mcp config for mcping
config/mcp_config.yaml
config/mcp_config.yml
# target config file for testing
features/support/targets.yml
# Generated test files
Expand Down
1 change: 1 addition & 0 deletions config/mcp_config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ mcp:
# MCP server network configuration (for HTTP transport only)
host: localhost # Host to bind to (default: localhost)
port: 3000 # Port to listen on (default: 3000)
#auth_token: ... # Bearer token for authentication (default: randomly generated, one-time token)

# Rate limiting (optional - defaults shown)
rate_limit:
Expand Down
48 changes: 48 additions & 0 deletions docs/metasploit-framework.wiki/How-to-use-Metasploit-MCP-Server.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ All configuration settings can be overridden by environment variables:
| `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) |
| `MSF_MCP_AUTH_TOKEN` | MCP server Bearer token for authentication (for HTTP transport) |

Example using environment variables:

Expand Down Expand Up @@ -175,6 +176,53 @@ When auto-start is disabled and no RPC server is running, you must start `msfrpc
msfrpcd -U your_username -P your_password -p 55553
```

## Authentication

The HTTP transport supports authentication through a Bearer token as set in the Authorization header (e.g.
`Authorization: Bearer ...`). By default, if the token is not set, a random token will be generated automatically and
printed when the MCP server starts.

For example:

```
Initializing MCP server...
Starting MCP server on HTTP transport...
Server listening on http://localhost:3000/
Authentication: Bearer token (auto-generated)
Configure your MCP client with: Authorization: Bearer 2fc41c38eccfe505b44cde1bc96dc4bf72e4abe163a689a3e25bd05e8c081cbd
Press Ctrl+C to shutdown
```

Using the automatically generated token means the MCP client's configuration will need to be updated each time the
server is restarted. A persistent token can be defined in two ways:

1. In the configuration file, by defining `auth_token` to a non-empty string under the `mcp` key, e.g.:

```yaml
mcp:
transport: http
# ... other config keys
auth_token: MY-SUPER-SECURE-TOKEN
```

2. By setting the `MSF_MCP_AUTH_TOKEN` environment variable. As with other configuration options, the environment
variable takes precedence over the configuration.

### Disabling Authentication

While not advisable, authentication can be disabled by setting the configuration to null or an empty string. In this
case the server will respond to HTTP requests from any client that can connect to it. Use caution when disabling
authentication.

```yaml
mcp:
transport: http
# ... other config keys
auth_token: null # DISABLE HTTP AUTHENTICATION
```

The same can be achieved through the environment variable, e.g. `MSF_MCP_AUTH_TOKEN="" ./msfmcpd --mcp-transport http`.

## MCP Tools

The server exposes 8 tools to AI applications via the MCP protocol.
Expand Down
1 change: 1 addition & 0 deletions lib/msf/core/mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ module MCP
require_relative 'mcp/logging/sinks/json_flatfile'
require_relative 'mcp/logging/sinks/sanitizing'
require_relative 'mcp/middleware/request_logger'
require_relative 'mcp/middleware/bearer_auth'

# Error classes
require_relative 'mcp/errors'
Expand Down
35 changes: 33 additions & 2 deletions lib/msf/core/mcp/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

require 'msf/core/mcp'
require 'optparse'
require 'securerandom'

module Msf::MCP
# Main application class that orchestrates the MCP server startup and lifecycle
Expand Down Expand Up @@ -273,10 +274,25 @@ def start_mcp_server
port = @config.dig(:mcp, :port) || 3000

if transport == :http
auth_token = resolve_auth_token
@output.puts "Starting MCP server on HTTP transport..."
@output.puts "Server listening on http://#{host}:#{port}"
@output.puts "Server listening on http://#{Rex::Socket.to_authority(host, port)}/"
case auth_token
when :disabled
@output.puts "Authentication: disabled"
auth_token = nil
when :enabled
@output.puts "Authentication: enabled"
auth_token = @config.dig(:mcp, :auth_token)
when :generated
auth_token = SecureRandom.hex(32)
@output.puts "Authentication: Bearer token (auto-generated)"
@output.puts " Configure your MCP client with: Authorization: Bearer #{auth_token}"
else
raise RuntimeError, 'auth_token did not resolve to a supported value.'
end
@output.puts "Press Ctrl+C to shutdown"
@mcp_server.start(transport: :http, host: host, port: port)
@mcp_server.start(transport: :http, host: host, port: port, auth_token: auth_token)
else
@output.puts "Starting MCP server on stdio transport..."
@output.puts "Server ready - waiting for MCP requests"
Expand All @@ -285,6 +301,21 @@ def start_mcp_server
end
end

# Determine the auth token state for the HTTP transport startup message.
#
# Returns one of three values:
# :disabled -- explicitly set to nil/empty; authentication is off
# :enabled -- set via config file or env var; caller should fetch from config
# :generated -- not configured; caller should generate and display a token
#
def resolve_auth_token
if @config[:mcp].key?(:auth_token)
@config[:mcp][:auth_token] ? :enabled : :disabled
else
:generated
end
end

# Error handlers

def handle_configuration_error(error)
Expand Down
20 changes: 19 additions & 1 deletion lib/msf/core/mcp/config/loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ def self.apply_defaults(config)
config[:mcp][:port] ||= 3000
end

# auth_token: only normalize if the key was explicitly provided.
# Absent key means "not configured" -- the application layer generates
# a token at startup so it can decide whether to print it.
# nil or "" becomes nil (authentication disabled); non-empty string is used as-is.
if config[:mcp].key?(:auth_token)
val = config[:mcp][:auth_token]
config[:mcp][:auth_token] = nil if val.nil? || (val.is_a?(String) && val.empty?)
end

config[:rate_limit][:enabled] = config[:rate_limit].fetch(:enabled, true)
config[:rate_limit][:requests_per_minute] ||= 60
config[:rate_limit][:burst_size] ||= 10
Expand Down Expand Up @@ -109,11 +118,20 @@ def self.apply_env_overrides(config)
# 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']

# MCP authentication -- env var overrides config/default
# unset -- leave whatever apply_defaults established
# set to "" -- nil (disable authentication)
# set to non-empty -- use as the bearer token
if ENV.key?('MSF_MCP_AUTH_TOKEN')
mcp_token = ENV['MSF_MCP_AUTH_TOKEN']
config[:mcp][:auth_token] = mcp_token.empty? ? nil : mcp_token
end
end

# Parse a string value into a boolean
#
# @param value [String] String to parse ('true', '1', 'yes' true; anything else false)
# @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)
Expand Down
43 changes: 43 additions & 0 deletions lib/msf/core/mcp/middleware/bearer_auth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

module Msf::MCP
module Middleware
##
# Rack middleware that enforces Bearer token authentication on every request.
#
# Skipped (pass-through) when no token is configured -- the token is always
# present when this middleware is mounted because {Server#start_http} only
# adds it to the stack when +auth_token+ is non-nil.
#
# Clients must send:
# Authorization: Bearer <token>
#
# Returns 401 with a WWW-Authenticate challenge on any mismatch.
# Comparison is constant-time via +Rack::Utils.secure_compare+ to prevent
# timing-based token enumeration.
#
class BearerAuth
UNAUTHORIZED = [
401,
{
'Content-Type' => 'application/json',
'WWW-Authenticate' => 'Bearer realm="msfmcp"'
},
['{"error":"Unauthorized"}']
].freeze

def initialize(app, auth_token:)
@app = app
@auth_token = auth_token
end

def call(env)
expected = "Bearer #{@auth_token}"
provided = env['HTTP_AUTHORIZATION'].to_s
return UNAUTHORIZED unless Rack::Utils.secure_compare(expected, provided)

@app.call(env)
end
Comment thread
zeroSteiner marked this conversation as resolved.
end
end
end
7 changes: 4 additions & 3 deletions lib/msf/core/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ def initialize(msf_client:, rate_limiter:)
# @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)
def start(transport: :stdio, host: 'localhost', port: 3000, auth_token: nil)
case transport
when :stdio
start_stdio
when :http
start_http(host, port)
start_http(host, port, auth_token)
else
raise ArgumentError, "Unknown transport: #{transport}. Use :stdio or :http"
end
Expand Down Expand Up @@ -108,7 +108,7 @@ def start_stdio
#
# @return [MCP::Server] The MCP server instance (for testing purposes)
#
def start_http(host, port)
def start_http(host, port, auth_token)
require 'rackup'
require 'rack/handler/puma'

Expand All @@ -118,6 +118,7 @@ def start_http(host, port)
# The transport itself is a Rack app (implements #call).
rack_app = Rack::Builder.new do
use Msf::MCP::Middleware::RequestLogger
use Msf::MCP::Middleware::BearerAuth, auth_token: auth_token.to_s if auth_token && !auth_token.to_s.empty?
run transport
end
Comment thread
zeroSteiner marked this conversation as resolved.

Expand Down
4 changes: 2 additions & 2 deletions spec/lib/msf/core/mcp/application_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,7 @@
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)
expect(mock_mcp_server).to receive(:start).with(hash_including(transport: :http, host: '0.0.0.0', port: 3000))

app.send(:start_mcp_server)

Expand All @@ -419,7 +419,7 @@
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)
expect(mock_mcp_server).to receive(:start).with(hash_including(transport: :http, host: 'localhost', port: 3000))

app.send(:start_mcp_server)
end
Expand Down
99 changes: 99 additions & 0 deletions spec/lib/msf/core/mcp/middleware/bearer_auth_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

require 'msf/core/mcp'
require 'rack'

RSpec.describe Msf::MCP::Middleware::BearerAuth do
let(:token) { 's3cr3t' }
let(:inner_app) { ->(_env) { [200, { 'Content-Type' => 'application/json' }, ['OK']] } }
let(:middleware) { described_class.new(inner_app, auth_token: token) }

def rack_env_for(authorization: nil)
env = Rack::MockRequest.env_for('http://localhost:3000/mcp', method: 'POST')
env['HTTP_AUTHORIZATION'] = authorization if authorization
env
end

describe 'UNAUTHORIZED' do
subject { described_class::UNAUTHORIZED }

it 'has status 401' do
expect(subject[0]).to eq(401)
end

it 'includes Content-Type application/json' do
expect(subject[1]['Content-Type']).to eq('application/json')
end

it 'includes a WWW-Authenticate Bearer challenge' do
expect(subject[1]['WWW-Authenticate']).to eq('Bearer realm="msfmcp"')
end

it 'has a JSON error body' do
expect(subject[2]).to eq(['{"error":"Unauthorized"}'])
end

it 'is frozen' do
expect(subject).to be_frozen
end
end

describe '#call' do
context 'with the correct Bearer token' do
it 'delegates to the inner app and returns its response' do
env = rack_env_for(authorization: "Bearer #{token}")
status, _headers, body = middleware.call(env)

expect(status).to eq(200)
expect(body).to eq(['OK'])
end
end

context 'with a wrong token value' do
it 'returns 401' do
env = rack_env_for(authorization: 'Bearer wrongtoken')
status, headers, body = middleware.call(env)

expect(status).to eq(401)
expect(headers['WWW-Authenticate']).to eq('Bearer realm="msfmcp"')
expect(body).to eq(['{"error":"Unauthorized"}'])
end
end

context 'with no Authorization header' do
it 'returns 401' do
env = rack_env_for
status, _headers, _body = middleware.call(env)

expect(status).to eq(401)
end
end

context 'with the wrong scheme' do
it 'returns 401' do
env = rack_env_for(authorization: "Basic #{token}")
status, _headers, _body = middleware.call(env)

expect(status).to eq(401)
end
end

context 'with the correct token but wrong case for the scheme' do
it 'returns 401' do
env = rack_env_for(authorization: "bearer #{token}")
status, _headers, _body = middleware.call(env)

expect(status).to eq(401)
end
end

context 'with trailing whitespace on the token' do
it 'returns 401' do
env = rack_env_for(authorization: "Bearer #{token} ")
status, _headers, _body = middleware.call(env)

expect(status).to eq(401)
end
end
end
end
Loading
Loading