Class: McpToolkit::Configuration

Inherits:
Object
  • Object
show all
Defined in:
lib/mcp_toolkit/configuration.rb

Overview

The single, injectable configuration object for an app's MCP server.

Generic but OPINIONATED: every setting has a sensible, vendor-neutral default, so a satellite needs to override only a handful of values. The two things an app almost always sets are server_name and the auth wiring (central_app_url for a satellite, or token_authenticator for the authority).

Whether ANY scope is required is decided PER TOOL, not per app: a resource declares required_permissions_scope "notifications__read" (or the registry declares default_required_permissions_scope once for all resources). There is no app-wide permission setting here.

Accessed through McpToolkit.config (or MCPToolkit.config) and mutated in a McpToolkit.configure { |c| ... } block.

Constant Summary collapse

MINIMUM_SIGNING_SECRET_BYTES =

Short enough to admit any real secret, long enough to catch a placeholder.

Deliberately NOT applied to the secret_key_base fallback, which is checked for presence only. The minimum exists to catch a placeholder a host typed into THIS setting; secret_key_base is Rails' own, is 128 chars in a real deployment, and is short only in environments where Rails generates a throwaway (a stock test env is ~15 bytes). A genuinely weak secret_key_base is an app-wide problem — signed cookies, message verifiers, Active Record encryption — and not this gem's to police from the outside.

32
PROTECTED_RESOURCE_WELL_KNOWN =

The well-known prefixes the two metadata documents hang off. The resource path is INSERTED after these, never appended to the origin — see oauth_protected_resource_path.

"/.well-known/oauth-protected-resource"
AUTHORIZATION_SERVER_WELL_KNOWN =
"/.well-known/oauth-authorization-server"
ACTIVE_CONTENT_SCHEMES =

Schemes a browser may treat as script or local content. A code handed to one is at best lost and at worst executed; no client legitimately registers one.

%w[javascript data vbscript file blob about view-source].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeConfiguration

Vendor-neutral defaults; apps override the auth wiring + identity as needed.



626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
# File 'lib/mcp_toolkit/configuration.rb', line 626

def initialize
  @server_name = "mcp-server"
  @server_version = "1.0.0"
  @server_instructions = nil

  @gateway_client_name = nil
  @gateway_client_version = nil

  @serializer_base = nil # set lazily in #serializer_base to avoid load-order issues

  @auth_role = :satellite
  @central_app_url = nil
  @introspect_path = "/mcp/tokens/introspect"
  @introspection_cache_ttl = 45
  @introspection_timeout = 10
  @account_resolver = ->() {  }

  @token_authenticator = nil

  initialize_oauth_bridge_defaults

  @cache_store = ActiveSupport::Cache::MemoryStore.new
  initialize_data_path_defaults

  @protocol_version = nil
  @supported_protocol_versions = McpToolkit::Protocol::SUPPORTED_VERSIONS
  @parent_controller = "ActionController::Base"
  @account_meta_key = "mcp-toolkit/account-id"
  @account_id_header = "X-MCP-Account-ID"

  initialize_authority_hook_defaults
  @generic_tool_name_prefix = ""

  @upstream_timeout = 10
  @upstream_list_ttl = 900 # 15 minutes
  @logger = nil

  @registry = McpToolkit::Registry.new
  @upstreams = McpToolkit::Gateway::UpstreamRegistry.new
end

Instance Attribute Details

#account_id_headerString

Returns:

  • (String)


499
500
501
# File 'lib/mcp_toolkit/configuration.rb', line 499

def 
  @account_id_header
end

#account_meta_keyString

Header / meta-key constants. Vendor-neutral defaults; an app on a specific central authority can rename them to match that authority's convention. These are the selectors a superuser/multi-account token uses to pin the active account.

Returns:

  • (String)


497
498
499
# File 'lib/mcp_toolkit/configuration.rb', line 497

def 
  @account_meta_key
end

#account_resolver#call

Resolves the central account id to the satellite's LOCAL scope root.

A satellite stores rows keyed by the central app's account id (synced via Kafka etc.). This callable receives the resolved central account_id and MUST return the object that Resource#scope blocks root on (typically the local Account). Return nil to signal "no local account" (=> Unauthorized).

c. = ->() { Account.find_by(synced_id: ) }

Defaults to the identity function: the resolved central account id is used directly as the scope root (suitable for an app whose scope blocks key on the central id, or for the authority itself).

Returns:

  • (#call)


96
97
98
# File 'lib/mcp_toolkit/configuration.rb', line 96

def 
  @account_resolver
end

#auth_roleSymbol

Returns :satellite (introspect tokens against a central app) or :authority (be the introspection provider + authenticate local tokens). A single app MAY be both — set :authority and still configure a central_app_url if it also exposes its own tools as a satellite.

Returns:

  • (Symbol)

    :satellite (introspect tokens against a central app) or :authority (be the introspection provider + authenticate local tokens). A single app MAY be both — set :authority and still configure a central_app_url if it also exposes its own tools as a satellite.



67
68
69
# File 'lib/mcp_toolkit/configuration.rb', line 67

def auth_role
  @auth_role
end

#bare_filter_value_semanticsSymbol

NOTE: (all data-path settings below): the list/get executors and the schema builder read the PROCESS-GLOBAL McpToolkit.config — a per-instance config bound to a provider affects tool prose only. Configure these globally.

How a BARE (non-operator) filter value is interpreted by the list executor:

:tokenized (default) — a comma-separated string becomes an IN set, the
"null" token (and a JSON null) matches NULL rows, an Array of non-null
scalars is an IN set (nil/Hash/nested-Array elements rejected), and an
empty string means "no filter".
:literal — the value is handed to the WHERE clause verbatim (an op-less
Hash is still rejected). This preserves an EXISTING API contract for a
host whose pre-gem endpoint matched bare values literally: "a,b" is one
literal string, "null" is the literal string, "" matches empty-string
rows, an Array (including nil elements) gets the adapter's native IN /
OR-IS-NULL handling.

Operator conditions ({ op:, value: }) behave identically in both modes.

Returns:

  • (Symbol)

    :tokenized or :literal



400
401
402
# File 'lib/mcp_toolkit/configuration.rb', line 400

def bare_filter_value_semantics
  @bare_filter_value_semantics
end

#cache_storeActiveSupport::Cache::Store, #read

The cache store backing sessions and introspection results. Must satisfy the ActiveSupport::Cache::Store contract (read/write/delete with expires_in:). Defaults to an in-process MemoryStore; a real deployment should set this to Rails.cache (or any shared store) so sessions survive across Puma workers.

Returns:

  • (ActiveSupport::Cache::Store, #read)


332
333
334
# File 'lib/mcp_toolkit/configuration.rb', line 332

def cache_store
  @cache_store
end

#central_app_urlString?

Returns base URL of the central auth app. The satellite POSTs <central_app_url>/mcp/tokens/introspect.

Returns:

  • (String, nil)

    base URL of the central auth app. The satellite POSTs <central_app_url>/mcp/tokens/introspect.



73
74
75
# File 'lib/mcp_toolkit/configuration.rb', line 73

def central_app_url
  @central_app_url
end

#extra_tool_providersArray<#tool_definitions, Class>

Additional tool providers (or bare TOOL classes, auto-wrapped in a SingleToolProvider) composed AFTER the generic Registry-backed tools when tool_provider is not explicitly assigned. The registry provider is always first, so a generic tool name resolves to it; extras only answer their own names.

config.extra_tool_providers = [MyApp::Tools::AuditLog]

Returns:

  • (Array<#tool_definitions, Class>)


600
601
602
# File 'lib/mcp_toolkit/configuration.rb', line 600

def extra_tool_providers
  @extra_tool_providers
end

#filter_operator_overridesHash{Symbol => Array<String>}

Per-column-type overrides for the operator sets advertised by resource_schema and enforced by the list executor, merged over McpToolkit::Filtering::OPERATORS_BY_TYPE. Lets a host preserve an EXISTING operator contract exactly — both the schema bytes and which conditions are accepted — e.g. { text: %w[eq in], date: %w[eq in] } for a pre-gem endpoint that never offered comparisons on those types. Empty by default (the gem's own sets apply).

Returns:

  • (Hash{Symbol => Array<String>})


422
423
424
# File 'lib/mcp_toolkit/configuration.rb', line 422

def filter_operator_overrides
  @filter_operator_overrides
end

#gateway_client_nameObject

The gateway handshake client name, defaulting to the server identity when the host hasn't split it. Read (not stored) so a server_name change before the split is set still flows through.



754
755
756
# File 'lib/mcp_toolkit/configuration.rb', line 754

def gateway_client_name
  @gateway_client_name || server_name
end

#gateway_client_versionObject

The gateway handshake client version, defaulting to the server version.



759
760
761
# File 'lib/mcp_toolkit/configuration.rb', line 759

def gateway_client_version
  @gateway_client_version || server_version
end

#generic_tool_name_prefixString

A prefix prepended to the four GENERIC, Registry-backed authority tool names (resources, resource_schema, get, list) served by McpToolkit::Authority::RegistryToolProvider. Lets a host NAMESPACE its generic tools — e.g. set "foo_" and they advertise (and resolve) as foo_resources, foo_resource_schema, foo_get, foo_list — so distinct MCP surfaces don't collide and existing clients keep a stable, host-chosen name. Empty by default, so the tools keep their bare base names. The prefix value is the host's; the gem names no app concept.

Returns:

  • (String)


614
615
616
# File 'lib/mcp_toolkit/configuration.rb', line 614

def generic_tool_name_prefix
  @generic_tool_name_prefix
end

#introspect_pathString?

Returns the introspect path appended to central_app_url.

Returns:

  • (String, nil)

    the introspect path appended to central_app_url.



75
76
77
# File 'lib/mcp_toolkit/configuration.rb', line 75

def introspect_path
  @introspect_path
end

#introspection_cache_ttlInteger

Returns seconds to cache an introspection result (positive AND negative) so a burst of tool calls does not hammer the central app.

Returns:

  • (Integer)

    seconds to cache an introspection result (positive AND negative) so a burst of tool calls does not hammer the central app.



78
79
80
# File 'lib/mcp_toolkit/configuration.rb', line 78

def introspection_cache_ttl
  @introspection_cache_ttl
end

#introspection_timeoutInteger

Returns HTTP open/read timeout for the introspection call.

Returns:

  • (Integer)

    HTTP open/read timeout for the introspection call.



80
81
82
# File 'lib/mcp_toolkit/configuration.rb', line 80

def introspection_timeout
  @introspection_timeout
end

#logger#warn, ...

Optional logger for gateway/session diagnostics. All call sites guard with logger&.warn / logger&.error, so nil (the default) silences them. A Rails host typically sets this to Rails.logger.

Returns:

  • (#warn, #error, nil)


623
624
625
# File 'lib/mcp_toolkit/configuration.rb', line 623

def logger
  @logger
end

#max_batch_sizeInteger?

Caps how many JSON-RPC calls a single POST batch may carry on the authority transport. Rate limiting is per-HTTP-request, so an uncapped batch would let one request fan out unbounded work (N tool executions / N upstream calls) under a single rate-limit tick. nil disables the cap.

Returns:

  • (Integer, nil)


466
467
468
# File 'lib/mcp_toolkit/configuration.rb', line 466

def max_batch_size
  @max_batch_size
end

#max_filter_valuesInteger?

Caps how many values an IN-set filter may resolve to, and how many operator conditions may be ANDed on a single attribute, so a valid token can't emit an unbounded IN clause / AND-chain (oversized SQL + Arel AST and expensive planning; rate limiting is opt-in via rate_limit_max_requests). Applies to the default :tokenized bare-value semantics and to { op:, value: } conditions. nil disables the cap.

Returns:

  • (Integer, nil)


458
459
460
# File 'lib/mcp_toolkit/configuration.rb', line 458

def max_filter_values
  @max_filter_values
end

#non_numeric_pk_orderSymbol

Default ordering for a resource whose primary key is NON-numeric (numeric PKs always order by :id):

:created_at (default) — ORDER BY created_at, <pk> (chronological pages
with a total-order tiebreaker).
:primary_key — ORDER BY <pk> only. Preserves an EXISTING API contract for
a host whose pre-gem endpoint ordered every list by id.

Returns:

  • (Symbol)

    :created_at or :primary_key



411
412
413
# File 'lib/mcp_toolkit/configuration.rb', line 411

def non_numeric_pk_order
  @non_numeric_pk_order
end

#oauth_allow_loopback_redirectsBoolean

Permits LOOPBACK redirect targets on any port without naming each one: http://127.0.0.1:54321/cb, http://localhost:*/cb, http://[::1]:*/cb.

This is the one target that cannot be allowlisted even in principle — an MCP client on an operator's machine listens on an ephemeral port chosen at runtime, so no list could enumerate it (RFC 8252 §7.3 exists for exactly this). And it is safe to accept unnamed for the same reason the list above cannot be opened up: a loopback address resolves on the operator's OWN machine, so the phishing described above — which needs the code to reach a REMOTE attacker — does not work through it.

Note what this deliberately does NOT cover: a private-use scheme (cursor://…, RFC 8252 §7.1). Those keep the code on the device too, but their redirect URI is a FIXED STRING, so it simply goes in the list above — there is no forcing reason to accept one unnamed. And whole schemes cannot be accepted generically anyway: separating a private-use scheme from a registered network one (ssh:, ldap:, gopher: — each naming a remote host) would mean enumerating the IANA registry, and a denylist of the ones you happened to think of is the shape that fails open.

A remote https:// callback is never covered by this, whatever it is set to.

OFF BY DEFAULT: switching it on says "any client on my operators' machines may receive a code", which is a decision, not a default — and so is an opt-in signal in its own right.

c.oauth_allow_loopback_redirects = true

Returns:

  • (Boolean)


219
220
221
# File 'lib/mcp_toolkit/configuration.rb', line 219

def oauth_allow_loopback_redirects
  @oauth_allow_loopback_redirects
end

#oauth_allowed_redirect_urisArray<String>

The exact redirect URIs an authorization code may be handed to — the allowlist a REMOTE client's redirect_uri is matched against by exact string. This is the bridge's load-bearing control: without it the authorize endpoint would be an open redirect that emits authorization codes.

Why it cannot just be opened up to "any client": the page is served from the host's OWN origin under its own certificate and asks for a live token, so an unvetted redirect_uri makes it a credential-phishing page hosted by the host itself — an attacker sends the operator an authorize link carrying the attacker's code_challenge, the operator pastes, and the code goes to the attacker, who redeems it with the verifier they chose. PKCE cannot help; they own the verifier. This list IS the redirect-URI registration a real authorization server does, and it is the only thing standing in for it.

Be precise about what it does NOT cover, because the boundary is easy to overstate. It binds which URL a code may be sent to — not whose session at that URL receives it. Point it at a MULTI-TENANT client (which is the usual case: a hosted MCP client is one callback shared by every user) and an attacker can still start a flow in their OWN account there, send the operator the resulting authorize link, and have the code land back at that client carrying the attacker's state. Whether the operator's token then ends up in the attacker's account is decided entirely by whether the CLIENT binds state to the browser session that began the flow (RFC 6819 §4.4.1.7; RFC 9700 §4.7.1) — an authorization server cannot bind a code to a session it never saw, so no amount of consent or authentication here would close it. A real authorization server has exactly the same exposure. Only allowlist clients you believe handle state correctly.

EMPTY BY DEFAULT. Empty, and with oauth_allow_loopback_redirects off, the bridge is DISABLED entirely (see oauth_bridge?) — so it cannot be switched on without naming who may receive a code, and a host that wants nothing to do with it sets nothing.

c.oauth_allowed_redirect_uris = ["https://client.example/callback"]

Returns:

  • (Array<String>)


161
162
163
# File 'lib/mcp_toolkit/configuration.rb', line 161

def oauth_allowed_redirect_uris
  @oauth_allowed_redirect_uris
end

#oauth_authorization_code_ttlInteger

Seconds an issued authorization code stays redeemable. Codes are single-use (read-and-deleted at exchange); this only bounds a code that is never redeemed. Short by design — a client exchanges immediately.

Returns:

  • (Integer)


239
240
241
# File 'lib/mcp_toolkit/configuration.rb', line 239

def oauth_authorization_code_ttl
  @oauth_authorization_code_ttl
end

#oauth_parent_controllerString

The parent class of the bridge's controller, SEPARATE from parent_controller and defaulting to ActionController::Base.

They are separate because the two controllers have opposite needs. The MCP transport is a JSON-only endpoint, so a host quite reasonably points parent_controller at ActionController::API — which cannot render an HTML view. The bridge's authorization page IS an HTML view. Deriving it from parent_controller would therefore force a host to weaken its transport's superclass just to switch the bridge on; keeping them apart means enabling the bridge changes nothing about the transport.

Point this at your own ApplicationController to inherit app branding (the page renders with layout: false regardless, so an app layout that needs asset-pipeline context is not pulled in).

Returns:

  • (String)


321
322
323
# File 'lib/mcp_toolkit/configuration.rb', line 321

def oauth_parent_controller
  @oauth_parent_controller
end

#oauth_resource_pathString

The path McpToolkit::Engine is mounted at, used to build the resource identifier, the issuer, the two metadata locations, and the bridge's own endpoint URLs (their origin comes from the live request, so every host name the app answers on works). MUST match the actual mount point, and the resource it yields MUST equal the MCP endpoint URL as an operator types it into their client.

This path is ALSO what keeps the bridge out of the origin's global namespace — see oauth_protected_resource_path.

Returns:

  • (String)


232
233
234
# File 'lib/mcp_toolkit/configuration.rb', line 232

def oauth_resource_path
  @oauth_resource_path
end

#parent_controllerString

The parent class (as a String, resolved via constantize) of the gem-provided McpToolkit::ServerController that McpToolkit::Engine mounts. Doorkeeper-style indirection so a satellite mounting the engine can keep ActionController::Base (NOT ::API) — e.g. for a logstasher helper_method hook — by setting c.parent_controller = "ApplicationController".

Returns:

  • (String)


489
490
491
# File 'lib/mcp_toolkit/configuration.rb', line 489

def parent_controller
  @parent_controller
end

#protocol_versionString?

Returns protocol version to pin on the underlying MCP::Server. nil lets the gem negotiate (recommended). Set only to force an older spec.

Returns:

  • (String, nil)

    protocol version to pin on the underlying MCP::Server. nil lets the gem negotiate (recommended). Set only to force an older spec.



472
473
474
# File 'lib/mcp_toolkit/configuration.rb', line 472

def protocol_version
  @protocol_version
end

#rate_limit_max_requestsInteger?

The built-in per-principal request cap enforced by the authority transport (McpToolkit::Authority::ControllerMethods#mcp_rate_limit!), counted against cache_store via McpToolkit::RateLimiter. nil (the default) DISABLES rate limiting entirely, so a pure host is unaffected until it opts in. Set an Integer to cap each principal to that many requests per rate_limit_window. The default mcp_rate_limit! reads this through the overridable mcp_rate_limit_max_requests hook, so a host that keeps the cap in its own constant/model overrides that hook rather than this value.

Returns:

  • (Integer, nil)


349
350
351
# File 'lib/mcp_toolkit/configuration.rb', line 349

def rate_limit_max_requests
  @rate_limit_max_requests
end

#rate_limit_windowInteger

The fixed rate-limit window, in seconds (default 3600 = 1 hour). Ignored while rate_limit_max_requests is nil.

Returns:

  • (Integer)


355
356
357
# File 'lib/mcp_toolkit/configuration.rb', line 355

def rate_limit_window
  @rate_limit_window
end

#rate_limiter#call?

OPTIONAL escape hatch that FULLY REPLACES the built-in limiter: a ->(controller:, principal:) that renders + halts when over the limit (or sets rate-limit headers when under). When set, mcp_rate_limit! delegates to it and the built-in (rate_limit_max_requests) is skipped. nil (the default) means the built-in runs instead. Most hosts want the built-in; reach for this only when the counting itself must live in app code.

Returns:

  • (#call, nil)


543
544
545
# File 'lib/mcp_toolkit/configuration.rb', line 543

def rate_limiter
  @rate_limiter
end

#registryMcpToolkit::Registry

The resource registry for this configuration. Each config carries its own so tests (and, in principle, multiple mounted servers) don't collide. The process-wide convenience McpToolkit.registry delegates to the active config's registry.



507
508
509
# File 'lib/mcp_toolkit/configuration.rb', line 507

def registry
  @registry
end

#serializer_baseObject

The serializer base, lazily defaulting to the gem's bundled DSL base. Lazy so McpToolkit::Serializer::Base is referenced after it has been required, regardless of file load order.



766
767
768
# File 'lib/mcp_toolkit/configuration.rb', line 766

def serializer_base
  @serializer_base ||= McpToolkit::Serializer::Base
end

#server_instructionsString?

Returns human-readable instructions returned on initialize.

Returns:

  • (String, nil)

    human-readable instructions returned on initialize.



26
27
28
# File 'lib/mcp_toolkit/configuration.rb', line 26

def server_instructions
  @server_instructions
end

#server_nameString

Returns the MCP server name advertised in initialize.

Returns:

  • (String)

    the MCP server name advertised in initialize.



22
23
24
# File 'lib/mcp_toolkit/configuration.rb', line 22

def server_name
  @server_name
end

#server_versionString

Returns the MCP server version advertised in initialize.

Returns:

  • (String)

    the MCP server version advertised in initialize.



24
25
26
# File 'lib/mcp_toolkit/configuration.rb', line 24

def server_version
  @server_version
end

#session_data_builder#call?

Builds the opaque payload bound to a session on initialize. ->(principal:) returning a Hash (or nil for none). Lets a host bind e.g. { token_id: principal.id } so a revoked token can kill an in-flight session, WITHOUT overriding the controller's mcp_session_data. nil (the default) => an empty session payload.

Returns:

  • (#call, nil)


565
566
567
# File 'lib/mcp_toolkit/configuration.rb', line 565

def session_data_builder
  @session_data_builder
end

#session_ttlInteger

Returns session sliding-TTL in seconds.

Returns:

  • (Integer)

    session sliding-TTL in seconds.



335
336
337
# File 'lib/mcp_toolkit/configuration.rb', line 335

def session_ttl
  @session_ttl
end

#sql_sanitizer#sanitize_sql_like

Escapes LIKE wildcards in matches / does_not_match filter values so they match literally. Must respond to sanitize_sql_like(string). Defaults to the ActiveRecord-backed McpToolkit::SqlSanitizer; a non-Rails host (or a test) can inject its own.

Returns:

  • (#sanitize_sql_like)


378
379
380
# File 'lib/mcp_toolkit/configuration.rb', line 378

def sql_sanitizer
  @sql_sanitizer
end

#superuser_resolver#call?

Optional resolver deciding whether a principal is a SUPERUSER — a cross-tenant caller that may reach superusers_only! resources. ->(principal) -> Boolean. When set, McpToolkit::Authority::Context#superuser? calls it; when nil (the default) the context falls back to duck-typing principal.superuser? (false when the principal doesn't respond to it). Superuser is FULLY OPTIONAL: a host with no such concept leaves this nil and flags no superusers_only! resource, so no caller is ever a superuser.

Returns:

  • (#call, nil)


368
369
370
# File 'lib/mcp_toolkit/configuration.rb', line 368

def superuser_resolver
  @superuser_resolver
end

#supported_protocol_versionsArray<String>

The protocol versions the hand-rolled AUTHORITY dispatcher (McpToolkit::Dispatcher) negotiates, newest first. initialize echoes the requested version when it is in this set, else the first (latest). Defaults to McpToolkit::Protocol::SUPPORTED_VERSIONS; override to pin a host's own set.

Returns:

  • (Array<String>)


480
481
482
# File 'lib/mcp_toolkit/configuration.rb', line 480

def supported_protocol_versions
  @supported_protocol_versions
end

#token_authenticator#call?

Looks up + verifies a plaintext bearer token locally, returning a token object (duck-typed, see below) or nil. This is the authority's AccessToken.authenticate(plaintext) equivalent. Required for the :authority role; unused by a pure satellite.

c.token_authenticator = ->(plaintext) { AccessToken.authenticate(plaintext) }

The returned token object must respond to the methods McpToolkit::Auth::Authority#introspection_payload reads (see that module for the contract): kind, account_id, account_ids, expires_at, scopes. A touch_last_used! method, if present, is called.

Returns:

  • (#call, nil)


113
114
115
# File 'lib/mcp_toolkit/configuration.rb', line 113

def token_authenticator
  @token_authenticator
end

#tool_providerObject



587
588
589
# File 'lib/mcp_toolkit/configuration.rb', line 587

def tool_provider
  @tool_provider || composed_tool_provider
end

#upstream_list_ttlInteger

Returns TTL (s) for an upstream's cached, namespaced tool list in McpToolkit::Gateway::Aggregator.

Returns:

  • (Integer)

    TTL (s) for an upstream's cached, namespaced tool list in McpToolkit::Gateway::Aggregator.



516
517
518
# File 'lib/mcp_toolkit/configuration.rb', line 516

def upstream_list_ttl
  @upstream_list_ttl
end

#upstream_timeoutInteger

Returns HTTP open/read timeout (s) for a gateway's calls to an upstream MCP server (McpToolkit::Gateway::Client).

Returns:

  • (Integer)

    HTTP open/read timeout (s) for a gateway's calls to an upstream MCP server (McpToolkit::Gateway::Client).



513
514
515
# File 'lib/mcp_toolkit/configuration.rb', line 513

def upstream_timeout
  @upstream_timeout
end

#upstreamsMcpToolkit::Gateway::UpstreamRegistry (readonly)

The registry of upstream MCP servers this gateway aggregates + proxies to. Each config carries its own (like registry), so it resets with a fresh config. Register via the register_upstream sugar below or directly on this instance. Empty unless the app registers upstreams, so a non-gateway app is unaffected.



525
526
527
# File 'lib/mcp_toolkit/configuration.rb', line 525

def upstreams
  @upstreams
end

#usage_flusher#call?

Persists accumulated usage after the response (an after_action). ->(controller:). MUST never affect the MCP response. nil = no flush.

Returns:

  • (#call, nil)


556
557
558
# File 'lib/mcp_toolkit/configuration.rb', line 556

def usage_flusher
  @usage_flusher
end

#usage_recorder#call?

Records ONE usage event for a single JSON-RPC call (called per batch element). ->(request_data:, account:, principal:, controller:). MUST never affect the MCP response. nil = no metering.

Returns:

  • (#call, nil)


550
551
552
# File 'lib/mcp_toolkit/configuration.rb', line 550

def usage_recorder
  @usage_recorder
end

Instance Method Details

#authority?Boolean

Returns whether this app authenticates tokens locally / answers introspection.

Returns:

  • (Boolean)

    whether this app authenticates tokens locally / answers introspection.



777
778
779
# File 'lib/mcp_toolkit/configuration.rb', line 777

def authority?
  auth_role.to_sym == :authority
end

#initialize_authority_hook_defaultsObject

The authority transport's injection points all default to nil (a no-op): a pure satellite/gateway never touches them. rate_limit_window is the sole non-nil default (the window size only matters once a cap opts in).



716
717
718
719
720
721
722
723
724
725
726
# File 'lib/mcp_toolkit/configuration.rb', line 716

def initialize_authority_hook_defaults
  @rate_limiter = nil
  @usage_recorder = nil
  @usage_flusher = nil
  @session_data_builder = nil
  @tool_provider = nil
  @extra_tool_providers = []
  @rate_limit_max_requests = nil # nil = rate limiting disabled
  @rate_limit_window = 3600 # 1 hour
  @superuser_resolver = nil # nil = duck-type principal.superuser?
end

#initialize_data_path_defaultsObject

Session-TTL and list-executor defaults: the :tokenized / :created_at data-path semantics (a host preserving a pre-gem contract overrides these — see each accessor's docs).



682
683
684
685
686
687
688
689
690
691
# File 'lib/mcp_toolkit/configuration.rb', line 682

def initialize_data_path_defaults
  @session_ttl = 3600 # 1 hour

  @sql_sanitizer = McpToolkit::SqlSanitizer.new
  @bare_filter_value_semantics = :tokenized
  @non_numeric_pk_order = :created_at
  @filter_operator_overrides = {}
  @max_filter_values = 500
  @max_batch_size = 50
end

#initialize_oauth_bridge_defaultsObject

OAuth bridge defaults. The empty redirect allowlist AND the off native-client switch are jointly what keep the bridge OFF (oauth_bridge?), so a host that never configures it is unaffected.



670
671
672
673
674
675
676
677
# File 'lib/mcp_toolkit/configuration.rb', line 670

def initialize_oauth_bridge_defaults
  @oauth_allowed_redirect_uris = []
  @oauth_allow_loopback_redirects = false
  @oauth_resource_path = "/mcp"
  @oauth_authorization_code_ttl = 60
  @oauth_parent_controller = "ActionController::Base"
  @oauth_signing_secret = nil # falls back to Rails' secret_key_base — see the reader
end

#introspect_urlObject

Full introspection URL the satellite POSTs to. Raises a clear error if the central URL was never configured.



926
927
928
929
930
# File 'lib/mcp_toolkit/configuration.rb', line 926

def introspect_url
  raise McpToolkit::Errors::ConfigurationError, "central_app_url is not configured" if central_app_url.to_s.empty?

  "#{central_app_url.chomp("/")}#{introspect_path}"
end

#oauth_authorization_server_pathString

Where the authorization-server metadata (RFC 8414) answers. Path-inserted for the same reason, and it MUST agree with the issuer: a client constructs this URL from the issuer it was given.

Returns:

  • (String)


821
822
823
# File 'lib/mcp_toolkit/configuration.rb', line 821

def oauth_authorization_server_path
  "#{AUTHORIZATION_SERVER_WELL_KNOWN}#{oauth_resource_path_component}"
end

#oauth_bridge?Boolean

Whether the OAuth authorization bridge is live: its routes are drawn, and the authority transport advertises it on a 401 via WWW-Authenticate.

Gated on three conditions, each for its own reason.

AUTHORITY-ONLY, because the flow hands back a token this app itself authenticates — a satellite's tokens belong to its central app, so there is nothing here for it to authorize against.

A token_authenticator must be set, because the bridge cannot function without one: it verifies the pasted token through it on both legs. Gated rather than left to fail at request time so a misconfigured host serves no bridge at all, instead of an authorization page that accepts an operator's token and then errors — the sibling introspection endpoint fails safe the same way.

An oauth_signing_secret must resolve, for the same reason: without one the bridge cannot seal a code's payload, and it must not fall back to sealing with something weaker. A Rails host gets secret_key_base for free.

And at least one redirect target must be named — an allowlist entry, or the loopback switch — so the bridge cannot be running without a bound answer to "who may receive a code". Both are empty/off by default, which is what makes an unconfigured host byte-identical to one without the bridge.

NOT gated on a shared cache_store, deliberately, even though a per-worker one breaks the flow (see oauth_per_process_cache_store?): a MemoryStore is correct in a single process, Rails.cache IS a MemoryStore in a stock development environment, and the gem cannot see the worker count. Gating would make the bridge undevelopable locally to prevent a production mistake, so that one is a loud warning at boot instead.

Returns:

  • (Boolean)


858
859
860
861
862
863
864
# File 'lib/mcp_toolkit/configuration.rb', line 858

def oauth_bridge?
  return false unless authority?
  return false if token_authenticator.nil?
  return false if oauth_signing_secret.to_s.empty?

  Array(oauth_allowed_redirect_uris).any? || !!oauth_allow_loopback_redirects
end

#oauth_parse_redirect_uri(uri) ⇒ Object



905
906
907
908
909
# File 'lib/mcp_toolkit/configuration.rb', line 905

def oauth_parse_redirect_uri(uri)
  URI.parse(uri)
rescue URI::InvalidURIError
  nil
end

#oauth_per_process_cache_store?Boolean

Whether cache_store is per-process, which the bridge cannot survive on a multi-worker deployment: a code written on the worker that ran leg 1 is invisible to the worker that runs leg 2, so the exchange reads nil and answers invalid_grant — roughly (N-1)/N of the time, AFTER the operator has pasted a live token, and intermittently enough to read as a fluke rather than a misconfiguration. (It also quietly voids the payload sealing: a per-process store has no snapshot to steal, and its key would be in the same heap.)

Fine in one process, which is why this warns rather than gates.

Returns:

  • (Boolean)


920
921
922
# File 'lib/mcp_toolkit/configuration.rb', line 920

def oauth_per_process_cache_store?
  cache_store.is_a?(ActiveSupport::Cache::MemoryStore)
end

#oauth_protected_resource_pathString

Where the protected-resource metadata (RFC 9728) answers, and where WWW-Authenticate points.

Path-SCOPED (/.well-known/oauth-protected-resource/mcp), never the bare path, because the bare ones are ORIGIN-GLOBAL: they describe the authorization server of the whole origin, which on a host already running an unrelated OAuth provider is that provider's claim to make, not an MCP server's. RFC 8414 §3.1 exists for this — "Using path components enables supporting multiple issuers per host" — and MCP's 2025-11-25 authorization spec gives a path-ful issuer no root fallback, so scoping is the correct reading rather than a workaround.

A root-mounted endpoint has no path to insert and gets the bare paths, which is correct there: it really is that origin's only authorization server.

Returns:

  • (String)


812
813
814
# File 'lib/mcp_toolkit/configuration.rb', line 812

def oauth_protected_resource_path
  "#{PROTECTED_RESOURCE_WELL_KNOWN}#{oauth_resource_path_component}"
end

#oauth_redirect_scheme_problem(scheme, parsed) ⇒ Object



892
893
894
895
896
897
898
899
900
901
902
903
# File 'lib/mcp_toolkit/configuration.rb', line 892

def oauth_redirect_scheme_problem(scheme, parsed)
  if ACTIVE_CONTENT_SCHEMES.include?(scheme)
    return "#{scheme}: is not a redirect target — a browser treats it as script or local content"
  end
  return nil unless scheme == "http"
  # RFC 8252 §7.3: cleartext is fine to a loopback address, which never leaves
  # the operator's machine. Anywhere else it puts the code on the wire.
  return nil if McpToolkit::Oauth.loopback_host?(parsed.host)

  "it is cleartext http to a remote host, which would put the authorization code on the wire — " \
    "use https (cleartext is only accepted for loopback)"
end

#oauth_redirect_uri_problem(uri) ⇒ Object

Why a given allowlist entry must not or cannot receive a code, or nil if it may. A lint over values the HOST wrote, not a boundary against an attacker — which is why naming bad schemes is sound here and would not be at request time: nothing an attacker sends reaches this list, so there is no unlisted scheme for them to slip through. (Request-time policy takes the opposite shape for exactly that reason — see McpToolkit::Oauth::ControllerMethods#mcp_oauth_loopback_redirect_uri?.)



873
874
875
876
877
878
879
880
881
882
883
884
885
886
# File 'lib/mcp_toolkit/configuration.rb', line 873

def oauth_redirect_uri_problem(uri)
  parsed = oauth_parse_redirect_uri(uri)
  return "it is not a valid URI" if parsed.nil?

  scheme = parsed.scheme&.downcase
  return "it names no scheme" if scheme.nil?
  return "it carries a fragment, which OAuth forbids on a redirect_uri" unless parsed.fragment.nil?
  unless parsed.opaque.nil?
    return "it is opaque (#{scheme}:#{parsed.opaque}) so it cannot carry the code — " \
           "write it with a path, e.g. #{scheme}:/#{parsed.opaque}"
  end

  oauth_redirect_scheme_problem(scheme, parsed)
end

#oauth_resource_path_componentString

oauth_resource_path normalized for URL building: no trailing slash, and empty when the MCP endpoint IS the origin root (where there is no path component to insert).

Returns:

  • (String)

    e.g. "/mcp", or "" for a root-mounted endpoint.



792
793
794
795
# File 'lib/mcp_toolkit/configuration.rb', line 792

def oauth_resource_path_component
  path = oauth_resource_path.to_s.chomp("/")
  path == "/" ? "" : path
end

#oauth_signing_secretObject

Reads the configured secret, else the Rails app's secret_key_base. Resolved lazily rather than in the initializer: the gem loads before a Rails app is fully configured, and a non-Rails host has no Rails constant at all.



297
298
299
300
301
302
303
# File 'lib/mcp_toolkit/configuration.rb', line 297

def oauth_signing_secret
  return @oauth_signing_secret if @oauth_signing_secret

  return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application

  ::Rails.application.secret_key_base
end

#oauth_signing_secret=(secret) ⇒ String?

The server-held secret mixed into the key that seals a cached authorization code's payload (McpToolkit::Oauth::ControllerMethods#mcp_oauth_encryptor).

It exists because the code alone must NOT be the key: Rails logs an authorization code twice per flow at INFO, so the logs would otherwise carry the key to the cache entry. This secret never reaches a log or a response, so the cache and the logs together still open nothing without it.

Defaults to the Rails app's secret_key_base (lazily, so load order and a non-Rails host are both fine), which is exactly the "server-held, in ENV, never logged" property wanted — so a Rails host configures nothing. A non-Rails host MUST set it; the bridge refuses to run without one (oauth_bridge?), rather than silently sealing with a weak key.

c.oauth_signing_secret = ENV.fetch("MCP_OAUTH_SIGNING_SECRET")

Use fetch, not ENV["..."]: a nil from a missing var falls back to secret_key_base silently, leaving a host believing it runs a dedicated secret when it does not.

Rotating it invalidates in-flight codes (a 60s window), nothing else — the tokens themselves are the host's and are untouched.

Validated at assignment, like oauth_allowed_redirect_uris= — a non-String here passed oauth_bridge?, drew the routes, and then raised a TypeError out of OpenSSL::HMAC at request time: after the operator pasted a live token and a code was already cached. That is the exact failure the sibling setter exists to prevent, so it gets the same treatment.

Returns:

  • (String, nil)

Raises:

  • (ArgumentError)


271
272
273
274
275
276
277
278
279
280
281
# File 'lib/mcp_toolkit/configuration.rb', line 271

def oauth_signing_secret=(secret)
  raise ArgumentError, "oauth_signing_secret must be a String, got #{secret.class}" if secret && !secret.is_a?(String)

  if secret.is_a?(String) && !secret.empty? && secret.bytesize < MINIMUM_SIGNING_SECRET_BYTES
    raise ArgumentError,
          "oauth_signing_secret is #{secret.bytesize} bytes; use at least " \
          "#{MINIMUM_SIGNING_SECRET_BYTES} (a real secret_key_base is 128)"
  end

  @oauth_signing_secret = secret
end

#register_upstream(key:, url:, public_tool_list: true) ⇒ Object

Config sugar: register a gateway upstream. Delegates to upstreams.register, so a blank url is ignored (an unconfigured upstream is simply absent). Pass public_tool_list: false for an upstream whose tool list varies by caller privilege, to opt it out of the shared list cache.

c.register_upstream(key: "notifications", url: ENV["NOTIFICATIONS_SERVER_URL"])


734
735
736
# File 'lib/mcp_toolkit/configuration.rb', line 734

def register_upstream(key:, url:, public_tool_list: true)
  upstreams.register(key:, url:, public_tool_list:)
end

#register_upstreams_from_env(mapping, env: ENV) ⇒ Object

Declares the gateway's upstreams from an ENV-var map — { key => env var name } — the shape every authority host repeats: resets the registry first (idempotent across code reloads, where the registration typically re-runs in a to_prepare), and an upstream whose ENV url is blank is never registered, so an unconfigured environment behaves like no-upstreams. env is injectable for tests.

config.register_upstreams_from_env("billing" => "BILLING_MCP_URL")


746
747
748
749
# File 'lib/mcp_toolkit/configuration.rb', line 746

def register_upstreams_from_env(mapping, env: ENV)
  upstreams.reset!
  mapping.each { |key, env_var| register_upstream(key:, url: env[env_var.to_s]) }
end

#satellite?Boolean

Returns whether this app introspects tokens against a central app.

Returns:

  • (Boolean)

    whether this app introspects tokens against a central app.



771
772
773
# File 'lib/mcp_toolkit/configuration.rb', line 771

def satellite?
  auth_role.to_sym == :satellite || central_app_url
end