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 @@ -4,3 +4,6 @@

# Ignore gem files
*.gem

# Ignore IDE files
.idea/
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ FastMcp.mount_in_rails(
# whitelist specific ips to if you want to run on localhost and allow connections from other IPs
# allowed_ips: ['127.0.0.1', '::1']
# authenticate: true, # Uncomment to enable authentication
# auth_token: 'your-token' # Required if authenticate: true
# auth_token: 'your-token', # Required if authenticate: true. Accepts a String, Array of Strings, or a Proc that returns either.
) do |server|
Rails.application.config.after_initialize do
# FastMcp will automatically discover and register:
Expand Down Expand Up @@ -388,14 +388,26 @@ FastMcp.rack_middleware(app,

### Authentication

Fast MCP supports token-based authentication for all connections:
Fast MCP supports token-based authentication for all connections. The `auth_token` option accepts a static String, an Array of Strings (to allow multiple valid tokens), or a Proc that returns either:

```ruby
# Enable authentication
# Enable authentication with a static token
FastMcp.authenticated_rack_middleware(app,
auth_token: 'your-secret-token',
# other options...
)

# Enable authentication with multiple valid tokens
FastMcp.authenticated_rack_middleware(app,
auth_token: ['token-one', 'token-two'],
# other options...
)

# Enable authentication with a Proc (evaluated at initialization)
FastMcp.authenticated_rack_middleware(app,
auth_token: -> { ENV.fetch('MCP_AUTH_TOKENS').split(',') },
# other options...
)
```

## 📖 Documentation
Expand Down
18 changes: 15 additions & 3 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,26 @@ Fast MCP supports token-based authentication for all connections to ensure only

### Basic Authentication

To enable authentication, use the `authenticated_rack_middleware` method:
To enable authentication, use the `authenticated_rack_middleware` method. The `auth_token` option accepts a static String, an Array of Strings, or a Proc that returns either:

```ruby
# Enable authentication
# Static token
FastMcp.authenticated_rack_middleware(app,
auth_token: 'your-secret-token',
# other options...
)

# Multiple valid tokens
FastMcp.authenticated_rack_middleware(app,
auth_token: ['token-one', 'token-two'],
# other options...
)

# Proc evaluated at initialization (returns a String or Array of Strings)
FastMcp.authenticated_rack_middleware(app,
auth_token: -> { ENV.fetch('MCP_AUTH_TOKENS').split(',') },
# other options...
)
```

### Custom Authentication Headers
Expand Down Expand Up @@ -110,7 +122,7 @@ Here are some best practices to enhance the security of your MCP server:
1. **Always validate Origin headers** (enabled by default)
2. **Use authentication** for all MCP endpoints in production
3. **Deploy behind HTTPS** in production environments
4. **Keep your auth_token secret** and rotate it regularly
4. **Keep your auth_token secret** and rotate it regularly. Use a Proc to load tokens dynamically from environment variables or a secrets manager
5. **Implement proper error handling** to avoid leaking sensitive information
6. **Validate inputs thoroughly** in your tool implementations
7. **Implement rate limiting** for MCP endpoints to prevent abuse
Expand Down
5 changes: 5 additions & 0 deletions examples/authenticated_rack_middleware.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ def content
end

# Create the MCP middleware
# auth_token accepts a String, Array of Strings, or a Proc that returns either.
# Examples:
# auth_token: 'secret'
# auth_token: ['token-one', 'token-two']
# auth_token: -> { ENV.fetch('MCP_AUTH_TOKENS').split(',') }
mcp_app = FastMcp.authenticated_rack_middleware(app, name: 'example-mcp-server', version: '1.0.0',
auth_token: 'secret') do |server|
# Register tool classes
Expand Down
2 changes: 1 addition & 1 deletion examples/rails-demo-app/config/initializers/fast_mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
# Add allowed origins below, it defaults to Rails.application.config.hosts
# allowed_origins: ['localhost', '127.0.0.1', 'example.com', /.*\.example\.com/],
# authenticate: true, # Uncomment to enable authentication
# auth_token: 'your-token' # Required if authenticate: true
# auth_token: 'your-token', # Required if authenticate: true. Accepts a String, Array of Strings, or a Proc that returns either.
) do |server|
Rails.application.config.after_initialize do
# FastMcp will automatically discover and register:
Expand Down
8 changes: 6 additions & 2 deletions lib/fast_mcp.rb
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ def self.rack_middleware(app, options = {})
# @param options [Hash] Options for the middleware
# @option options [String] :name The name of the server
# @option options [String] :version The version of the server
# @option options [String] :auth_token The authentication token
# @option options [String, Array<String>, Proc] :auth_token The authentication token. Accepts a static String,
# an Array of Strings (to allow multiple valid tokens), or a Proc that returns either.
# A Proc is evaluated once at initialization.
# @option options [Array<String,Regexp>] :allowed_origins List of allowed origins for DNS rebinding protection
# @yield [server] A block to configure the server
# @yieldparam server [FastMcp::Server] The server to configure
Expand Down Expand Up @@ -115,7 +117,9 @@ def self.register_resources(*resources)
# @option options [String] :sse_route The route for the SSE endpoint
# @option options [Logger] :logger The logger to use
# @option options [Boolean] :authenticate Whether to use authentication
# @option options [String] :auth_token The authentication token
# @option options [String, Array<String>, Proc] :auth_token The authentication token. Accepts a static String,
# an Array of Strings (to allow multiple valid tokens), or a Proc that returns either.
# A Proc is evaluated once at initialization.
# @option options [Array<String,Regexp>] :allowed_origins List of allowed origins for DNS rebinding protection
# @yield [server] A block to configure the server
# @yieldparam server [FastMcp::Server] The server to configure
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
# whitelist specific ips to if you want to run on localhost and allow connections from other IPs
# allowed_ips: ['127.0.0.1', '::1'],
# authenticate: true, # Uncomment to enable authentication
# auth_token: 'your-token', # Required if authenticate: true
# auth_token: 'your-token', # Required if authenticate: true. Accepts a String, Array of Strings, or a Proc that returns either.
) do |server|
Rails.application.config.after_initialize do
# FastMcp will automatically discover and register:
Expand Down
9 changes: 7 additions & 2 deletions lib/mcp/transports/authenticated_rack_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ class AuthenticatedRackTransport < RackTransport
def initialize(app, server, options = {})
super

@auth_token = options[:auth_token]
auth_token_option = options[:auth_token]
@auth_token = auth_token_option.is_a?(Proc) ? auth_token_option.call : auth_token_option
@auth_header_name = options[:auth_header_name] || 'Authorization'
@auth_exempt_paths = options[:auth_exempt_paths] || []
@auth_enabled = !@auth_token.nil?
Expand Down Expand Up @@ -36,7 +37,11 @@ def exempt_from_auth?(path)
end

def valid_token?(token)
token == @auth_token
if @auth_token.is_a?(Array)
@auth_token.include?(token)
else
token == @auth_token
end
end

def unauthorized_response(request)
Expand Down
145 changes: 145 additions & 0 deletions spec/mcp/transports/authenticated_rack_transport_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,151 @@
end
end

context 'with Proc auth_token' do
context 'when Proc returns a single token string' do
let(:auth_token) { -> { 'proc-generated-token' } }

it 'resolves the Proc at initialization' do
expect(transport.instance_variable_get(:@auth_token)).to eq('proc-generated-token')
end

it 'authenticates with the resolved token' do
env = {
'PATH_INFO' => '/not-mcp',
'HTTP_AUTHORIZATION' => 'Bearer proc-generated-token'
}

expect(app).to receive(:call).with(env).and_return([200, {}, ['OK']])
result = transport.call(env)
expect(result).to eq([200, {}, ['OK']])
end

it 'rejects an invalid token' do
env = {
'PATH_INFO' => '/mcp/messages',
'HTTP_AUTHORIZATION' => 'Bearer wrong-token'
}

result = transport.call(env)
expect(result[0]).to eq(401)
end
end

context 'when Proc returns an array of tokens' do
let(:auth_token) { -> { ['token-a', 'token-b', 'token-c'] } }

it 'resolves the Proc to an array at initialization' do
expect(transport.instance_variable_get(:@auth_token)).to eq(['token-a', 'token-b', 'token-c'])
end

it 'authenticates with any token from the array' do
%w[token-a token-b token-c].each do |token|
env = {
'PATH_INFO' => '/not-mcp',
'HTTP_AUTHORIZATION' => "Bearer #{token}"
}

expect(app).to receive(:call).with(env).and_return([200, {}, ['OK']])
result = transport.call(env)
expect(result).to eq([200, {}, ['OK']])
end
end

it 'rejects a token not in the array' do
env = {
'PATH_INFO' => '/mcp/messages',
'HTTP_AUTHORIZATION' => 'Bearer token-d'
}

result = transport.call(env)
expect(result[0]).to eq(401)
end
end

context 'when Proc returns nil' do
let(:auth_token) { -> { nil } }

it 'disables authentication' do
expect(transport.instance_variable_get(:@auth_enabled)).to be(false)
end

it 'allows unauthenticated requests' do
env = { 'PATH_INFO' => '/not-mcp' }

expect(app).to receive(:call).with(env).and_return([200, {}, ['OK']])
result = transport.call(env)
expect(result).to eq([200, {}, ['OK']])
end
end
end

context 'with Array auth_token' do
let(:auth_token) { ['token-one', 'token-two', 'token-three'] }

it 'stores the array as @auth_token' do
expect(transport.instance_variable_get(:@auth_token)).to eq(['token-one', 'token-two', 'token-three'])
end

it 'enables authentication' do
expect(transport.instance_variable_get(:@auth_enabled)).to be(true)
end

it 'accepts the first token in the array' do
env = {
'PATH_INFO' => '/not-mcp',
'HTTP_AUTHORIZATION' => 'Bearer token-one'
}

expect(app).to receive(:call).with(env).and_return([200, {}, ['OK']])
result = transport.call(env)
expect(result).to eq([200, {}, ['OK']])
end

it 'accepts the last token in the array' do
env = {
'PATH_INFO' => '/not-mcp',
'HTTP_AUTHORIZATION' => 'Bearer token-three'
}

expect(app).to receive(:call).with(env).and_return([200, {}, ['OK']])
result = transport.call(env)
expect(result).to eq([200, {}, ['OK']])
end

it 'rejects a token not in the array' do
env = {
'PATH_INFO' => '/mcp/messages',
'HTTP_AUTHORIZATION' => 'Bearer not-in-array'
}

result = transport.call(env)
expect(result[0]).to eq(401)
end

it 'rejects an empty token' do
env = {
'PATH_INFO' => '/mcp/messages',
'HTTP_AUTHORIZATION' => 'Bearer '
}

result = transport.call(env)
expect(result[0]).to eq(401)
end

it 'works with MCP message paths' do
env = {
'PATH_INFO' => '/mcp/messages',
'REQUEST_METHOD' => 'POST',
'HTTP_AUTHORIZATION' => 'Bearer token-two',
'REMOTE_ADDR' => '127.0.0.1',
'rack.input' => StringIO.new('{"jsonrpc":"2.0","method":"test","id":1}')
}

result = transport.call(env)
expect(result[0]).to eq(200)
end
end

context 'with authentication disabled' do
let(:transport) { described_class.new(app, server,logger: logger) }

Expand Down