diff --git a/.gitignore b/.gitignore index 5199e01..85cc651 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # Ignore gem files *.gem + +# Ignore IDE files +.idea/ diff --git a/README.md b/README.md index ae5a8ac..599b7c7 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/docs/security.md b/docs/security.md index 2184be0..fc8459f 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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 @@ -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 diff --git a/examples/authenticated_rack_middleware.rb b/examples/authenticated_rack_middleware.rb index 4bd8be9..b5b7b69 100755 --- a/examples/authenticated_rack_middleware.rb +++ b/examples/authenticated_rack_middleware.rb @@ -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 diff --git a/examples/rails-demo-app/config/initializers/fast_mcp.rb b/examples/rails-demo-app/config/initializers/fast_mcp.rb index aa39506..8d124c5 100644 --- a/examples/rails-demo-app/config/initializers/fast_mcp.rb +++ b/examples/rails-demo-app/config/initializers/fast_mcp.rb @@ -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: diff --git a/lib/fast_mcp.rb b/lib/fast_mcp.rb index 1b0968c..b3a5804 100644 --- a/lib/fast_mcp.rb +++ b/lib/fast_mcp.rb @@ -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, 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] :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 @@ -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, 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] :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 diff --git a/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb b/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb index c0dcc26..5a63b4f 100644 --- a/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb +++ b/lib/generators/fast_mcp/install/templates/fast_mcp_initializer.rb @@ -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: diff --git a/lib/mcp/transports/authenticated_rack_transport.rb b/lib/mcp/transports/authenticated_rack_transport.rb index 5a1c979..8d2ff31 100644 --- a/lib/mcp/transports/authenticated_rack_transport.rb +++ b/lib/mcp/transports/authenticated_rack_transport.rb @@ -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? @@ -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) diff --git a/spec/mcp/transports/authenticated_rack_transport_spec.rb b/spec/mcp/transports/authenticated_rack_transport_spec.rb index c354237..bf815a7 100644 --- a/spec/mcp/transports/authenticated_rack_transport_spec.rb +++ b/spec/mcp/transports/authenticated_rack_transport_spec.rb @@ -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) }