Module: Otto::MCP::Options

Defined in:
lib/otto/mcp/options.rb

Overview

Normalization of MCP configuration options.

Otto.new and Otto#enable_mcp! historically accepted different spellings of the same settings and the constructor dropped all but the endpoint (issue #258). Every path now funnels through Options.normalize.

One vocabulary: the canonical keys, their +mcp_+-prefixed variants (the constructor needs a namespace inside an options hash that also configures the rest of Otto), and tool_calls_per_minute, which is the name the rate-limiting middleware itself uses. Bare generic names such as endpoint, validation and rate_limiting are NOT accepted: rate_limiting: is Otto's own general rate-limiting option (a Hash), and the others were documented before #258 but never read.

Two strictness rules, selected with the scope argument:

:constructor (used by Otto.new / #configure_mcp) The constructor forwards its ENTIRE options hash here, most of which configures things other than MCP, so unrecognized keys are ignored. Unknown +mcp_+-prefixed keys still fail loud, since such a key can only have been meant for MCP.

:explicit (used by Otto#enable_mcp!) The caller is configuring MCP and nothing else, so any unrecognized key raises. That turns enable_mcp!(auth_token: 'x') — a singular-vs-plural typo that silently left the endpoint open — into a boot failure.

The +mcp_+-prefixed gating keys (+mcp_enabled+, mcp_http, mcp_stdio) decide whether MCP is enabled and are read by the constructor itself (Otto::Core::Configuration#configure_mcp) through Options.gating_options. The :constructor scope tolerates them; the :explicit scope rejects them, because enable_mcp!(mcp_http: false) would otherwise be accepted and still mount the endpoint. Their values must be exactly true or false: the constructor disables the endpoint on mcp_http == false, so a String "false" (from ENV.fetch or YAML) or nil (from an unset ENV[...]) would otherwise mount it.

Keys may be Strings or Symbols; they are symbolized before anything is read, so "auth_tokens" => [...] configures authentication exactly like auth_tokens:. A String key and its Symbol twin are two spellings of one option and conflict when their values differ.

Both scopes accept the canonical output of Options.normalize, so normalization is idempotent under either.

Constant Summary collapse

OPTION_ALIASES =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Canonical MCP option keys and every accepted alias.

{
          http_endpoint: %i[http_endpoint mcp_endpoint],
            auth_tokens: %i[auth_tokens mcp_auth_tokens],
      enable_validation: %i[enable_validation mcp_validation],
   enable_rate_limiting: %i[enable_rate_limiting mcp_rate_limiting],
    requests_per_minute: %i[requests_per_minute mcp_requests_per_minute],
       tools_per_minute: %i[tools_per_minute tool_calls_per_minute mcp_tool_calls_per_minute],
  allow_unauthenticated: %i[allow_unauthenticated mcp_allow_unauthenticated],
}.freeze
SCOPES =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

%i[constructor explicit].freeze
OPTION_DEFAULTS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Canonical defaults applied when no alias supplies a value.

{
          http_endpoint: '/_mcp',
            auth_tokens: [].freeze,
      enable_validation: true,
   enable_rate_limiting: true,
    requests_per_minute: 60,
       tools_per_minute: 20,
  allow_unauthenticated: false,
}.freeze
GATING_KEYS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

+mcp_+-prefixed constructor keys that gate whether MCP is enabled rather than configure the server. Otto.new reads them itself, so the :constructor scope tolerates them (and never emits them). #enable_mcp! cannot honour them, so the :explicit scope rejects them.

%i[mcp_enabled mcp_http mcp_stdio].freeze
RECOGNIZED_KEYS =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

Every key normalize recognizes, per scope.

{
  constructor: (OPTION_ALIASES.values.flatten + GATING_KEYS).freeze,
     explicit: OPTION_ALIASES.values.flatten.freeze,
}.freeze

Class Method Summary collapse

Class Method Details

.gating_options(opts) ⇒ Hash{Symbol=>Boolean}

Read the constructor-only gating keys (+mcp_enabled+, mcp_http, mcp_stdio) with the same String/Symbol tolerance as normalize.

Otto.new decides whether to enable MCP from these before it normalizes the rest, and it used to read them as raw Symbol keys, so Otto.new(nil, "mcp_enabled" => true) silently did nothing while the String-keyed "auth_tokens" beside it was documented as accepted.

Every gating value present must be exactly true or false. #configure_mcp disables the HTTP endpoint only on mcp_http == false, so mcp_http: ENV.fetch('MCP_HTTP', 'false') (a String, truthy) or mcp_http: ENV['MCP_HTTP'] with the variable unset (+nil+, not false) would each mount the endpoint the caller meant to disable. An explicit nil is rejected rather than treated as absent for that reason, matching the other boolean options and auth_tokens: nil.

Parameters:

  • opts (Hash, nil)

    raw constructor options

Returns:

  • (Hash{Symbol=>Boolean})

    the gating keys present in opts

Raises:

  • (ArgumentError)

    when a String key and its Symbol twin disagree, or when a gating value is anything but true or false (including the Strings "true" / "false", Integers and nil)



150
151
152
153
154
# File 'lib/otto/mcp/options.rb', line 150

def self.gating_options(opts)
  gating = symbolize_keys(opts.to_h).slice(*GATING_KEYS)
  gating.each { |key, value| coerce_boolean!(key, value) }
  gating
end

.normalize(opts = {}, scope = :explicit) ⇒ Hash

Normalize a constructor- or #enable_mcp!-style option hash into the single canonical shape consumed by Server#enable!.

scope is positional, not a keyword, so a brace-less hash at the call site (+normalize(auth_tokens: ['t'])+) binds to opts as intended.

Parameters:

  • opts (Hash) (defaults to: {})

    raw options; String and Symbol keys are equivalent

  • scope (Symbol) (defaults to: :explicit)

    :explicit (strict; the default) or :constructor (permissive about non-MCP keys)

Returns:

  • (Hash)

    canonical hash with keys :http_endpoint, :auth_tokens, :enable_validation, :enable_rate_limiting, :requests_per_minute, :tools_per_minute, :allow_unauthenticated

Raises:

  • (ArgumentError)

    on an unrecognized key, conflicting aliases or String/Symbol spellings, values of the wrong type, or auth tokens supplied but empty/blank



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/otto/mcp/options.rb', line 109

def self.normalize(opts = {}, scope = :explicit)
  raise ArgumentError, "Unknown MCP option scope #{scope.inspect}; expected one of #{SCOPES.inspect}" unless SCOPES.include?(scope)

  opts = symbolize_keys(opts.to_h)
  reject_unrecognized_keys!(opts, scope)

  canonical = OPTION_DEFAULTS.dup
  OPTION_ALIASES.each do |key, key_aliases|
    supplied = key_aliases.select { |a| opts.key?(a) }
    next if supplied.empty?

    values = supplied.map { |a| opts[a] }
    raise_conflict!(key, supplied.map { |a| [a, opts[a]] }) if values.uniq.size > 1

    canonical[key] = coerce_option(key, values.first)
  end

  canonical
end