Class: McpToolkit::Configuration
- Inherits:
-
Object
- Object
- McpToolkit::Configuration
- 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_basefallback, which is checked for presence only. The minimum exists to catch a placeholder a host typed into THIS setting;secret_key_baseis 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 weaksecret_key_baseis 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
- #account_id_header ⇒ String
-
#account_meta_key ⇒ String
Header / meta-key constants.
-
#account_resolver ⇒ #call
Resolves the central account id to the satellite's LOCAL scope root.
-
#auth_role ⇒ Symbol
:satellite (introspect tokens against a central app) or :authority (be the introspection provider + authenticate local tokens).
-
#bare_filter_value_semantics ⇒ Symbol
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. -
#cache_store ⇒ ActiveSupport::Cache::Store, #read
The cache store backing sessions and introspection results.
-
#central_app_url ⇒ String?
Base URL of the central auth app.
-
#extra_tool_providers ⇒ Array<#tool_definitions, Class>
Additional tool providers (or bare TOOL classes, auto-wrapped in a SingleToolProvider) composed AFTER the generic Registry-backed tools when
tool_provideris not explicitly assigned. -
#filter_operator_overrides ⇒ Hash{Symbol => Array<String>}
Per-column-type overrides for the operator sets advertised by
resource_schemaand enforced by the list executor, merged over McpToolkit::Filtering::OPERATORS_BY_TYPE. -
#gateway_client_name ⇒ Object
The gateway handshake client name, defaulting to the server identity when the host hasn't split it.
-
#gateway_client_version ⇒ Object
The gateway handshake client version, defaulting to the server version.
-
#generic_tool_name_prefix ⇒ String
A prefix prepended to the four GENERIC, Registry-backed authority tool names (
resources,resource_schema,get,list) served by McpToolkit::Authority::RegistryToolProvider. -
#introspect_path ⇒ String?
The introspect path appended to
central_app_url. -
#introspection_cache_ttl ⇒ Integer
Seconds to cache an introspection result (positive AND negative) so a burst of tool calls does not hammer the central app.
-
#introspection_timeout ⇒ Integer
HTTP open/read timeout for the introspection call.
-
#logger ⇒ #warn, ...
Optional logger for gateway/session diagnostics.
-
#max_batch_size ⇒ Integer?
Caps how many JSON-RPC calls a single POST batch may carry on the authority transport.
-
#max_filter_values ⇒ Integer?
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).
-
#non_numeric_pk_order ⇒ Symbol
Default ordering for a resource whose primary key is NON-numeric (numeric PKs always order by :id):.
-
#oauth_allow_loopback_redirects ⇒ Boolean
Permits LOOPBACK redirect targets on any port without naming each one:
http://127.0.0.1:54321/cb,http://localhost:*/cb,http://[::1]:*/cb. -
#oauth_allowed_redirect_uris ⇒ Array<String>
The exact redirect URIs an authorization code may be handed to — the allowlist a REMOTE client's
redirect_uriis matched against by exact string. -
#oauth_authorization_code_ttl ⇒ Integer
Seconds an issued authorization code stays redeemable.
-
#oauth_parent_controller ⇒ String
The parent class of the bridge's controller, SEPARATE from
parent_controllerand defaulting to ActionController::Base. -
#oauth_resource_path ⇒ String
The path McpToolkit::Engine is mounted at, used to build the
resourceidentifier, 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). -
#parent_controller ⇒ String
The parent class (as a String, resolved via
constantize) of the gem-provided McpToolkit::ServerController that McpToolkit::Engine mounts. -
#protocol_version ⇒ String?
Protocol version to pin on the underlying MCP::Server.
-
#rate_limit_max_requests ⇒ Integer?
The built-in per-principal request cap enforced by the authority transport (McpToolkit::Authority::ControllerMethods#mcp_rate_limit!), counted against
cache_storevia McpToolkit::RateLimiter. -
#rate_limit_window ⇒ Integer
The fixed rate-limit window, in seconds (default 3600 = 1 hour).
-
#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). -
#registry ⇒ McpToolkit::Registry
The resource registry for this configuration.
-
#serializer_base ⇒ Object
The serializer base, lazily defaulting to the gem's bundled DSL base.
-
#server_instructions ⇒ String?
Human-readable
instructionsreturned oninitialize. -
#server_name ⇒ String
The MCP server name advertised in
initialize. -
#server_version ⇒ String
The MCP server version advertised in
initialize. -
#session_data_builder ⇒ #call?
Builds the opaque payload bound to a session on
initialize. -
#session_ttl ⇒ Integer
Session sliding-TTL in seconds.
-
#sql_sanitizer ⇒ #sanitize_sql_like
Escapes LIKE wildcards in
matches/does_not_matchfilter values so they match literally. -
#superuser_resolver ⇒ #call?
Optional resolver deciding whether a principal is a SUPERUSER — a cross-tenant caller that may reach
superusers_only!resources. -
#supported_protocol_versions ⇒ Array<String>
The protocol versions the hand-rolled AUTHORITY dispatcher (McpToolkit::Dispatcher) negotiates, newest first.
-
#token_authenticator ⇒ #call?
Looks up + verifies a plaintext bearer token locally, returning a token object (duck-typed, see below) or nil.
- #tool_provider ⇒ Object
-
#upstream_list_ttl ⇒ Integer
TTL (s) for an upstream's cached, namespaced tool list in McpToolkit::Gateway::Aggregator.
-
#upstream_timeout ⇒ Integer
HTTP open/read timeout (s) for a gateway's calls to an upstream MCP server (McpToolkit::Gateway::Client).
-
#upstreams ⇒ McpToolkit::Gateway::UpstreamRegistry
readonly
The registry of upstream MCP servers this gateway aggregates + proxies to.
-
#usage_flusher ⇒ #call?
Persists accumulated usage after the response (an after_action).
-
#usage_recorder ⇒ #call?
Records ONE usage event for a single JSON-RPC call (called per batch element).
Instance Method Summary collapse
-
#authority? ⇒ Boolean
Whether this app authenticates tokens locally / answers introspection.
-
#initialize ⇒ Configuration
constructor
Vendor-neutral defaults; apps override the auth wiring + identity as needed.
-
#initialize_authority_hook_defaults ⇒ Object
The authority transport's injection points all default to nil (a no-op): a pure satellite/gateway never touches them.
-
#initialize_data_path_defaults ⇒ Object
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).
-
#initialize_oauth_bridge_defaults ⇒ Object
OAuth bridge defaults.
-
#introspect_url ⇒ Object
Full introspection URL the satellite POSTs to.
-
#oauth_authorization_server_path ⇒ String
Where the authorization-server metadata (RFC 8414) answers.
-
#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. - #oauth_parse_redirect_uri(uri) ⇒ Object
-
#oauth_per_process_cache_store? ⇒ Boolean
Whether
cache_storeis 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 answersinvalid_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. -
#oauth_protected_resource_path ⇒ String
Where the protected-resource metadata (RFC 9728) answers, and where
WWW-Authenticatepoints. - #oauth_redirect_scheme_problem(scheme, parsed) ⇒ Object
-
#oauth_redirect_uri_problem(uri) ⇒ Object
Why a given allowlist entry must not or cannot receive a code, or nil if it may.
-
#oauth_resource_path_component ⇒ String
oauth_resource_pathnormalized for URL building: no trailing slash, and empty when the MCP endpoint IS the origin root (where there is no path component to insert). -
#oauth_signing_secret ⇒ Object
Reads the configured secret, else the Rails app's
secret_key_base. -
#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).
-
#register_upstream(key:, url:, public_tool_list: true) ⇒ Object
Config sugar: register a gateway upstream.
-
#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 ato_prepare), and an upstream whose ENV url is blank is never registered, so an unconfigured environment behaves like no-upstreams. -
#satellite? ⇒ Boolean
Whether this app introspects tokens against a central app.
Constructor Details
#initialize ⇒ Configuration
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 = ->(synced_account_id) { synced_account_id } @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" @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_header ⇒ String
499 500 501 |
# File 'lib/mcp_toolkit/configuration.rb', line 499 def account_id_header @account_id_header end |
#account_meta_key ⇒ String
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.
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_resolver = ->(synced_account_id) { Account.find_by(synced_id: synced_account_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).
96 97 98 |
# File 'lib/mcp_toolkit/configuration.rb', line 96 def account_resolver @account_resolver end |
#auth_role ⇒ Symbol
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.
67 68 69 |
# File 'lib/mcp_toolkit/configuration.rb', line 67 def auth_role @auth_role end |
#bare_filter_value_semantics ⇒ Symbol
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.
400 401 402 |
# File 'lib/mcp_toolkit/configuration.rb', line 400 def @bare_filter_value_semantics end |
#cache_store ⇒ ActiveSupport::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.
332 333 334 |
# File 'lib/mcp_toolkit/configuration.rb', line 332 def cache_store @cache_store end |
#central_app_url ⇒ String?
Returns 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_providers ⇒ Array<#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]
600 601 602 |
# File 'lib/mcp_toolkit/configuration.rb', line 600 def extra_tool_providers @extra_tool_providers end |
#filter_operator_overrides ⇒ Hash{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).
422 423 424 |
# File 'lib/mcp_toolkit/configuration.rb', line 422 def filter_operator_overrides @filter_operator_overrides end |
#gateway_client_name ⇒ Object
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_version ⇒ Object
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_prefix ⇒ String
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.
614 615 616 |
# File 'lib/mcp_toolkit/configuration.rb', line 614 def generic_tool_name_prefix @generic_tool_name_prefix end |
#introspect_path ⇒ String?
Returns 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_ttl ⇒ Integer
Returns 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_timeout ⇒ Integer
Returns 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.
623 624 625 |
# File 'lib/mcp_toolkit/configuration.rb', line 623 def logger @logger end |
#max_batch_size ⇒ Integer?
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.
466 467 468 |
# File 'lib/mcp_toolkit/configuration.rb', line 466 def max_batch_size @max_batch_size end |
#max_filter_values ⇒ Integer?
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.
458 459 460 |
# File 'lib/mcp_toolkit/configuration.rb', line 458 def max_filter_values @max_filter_values end |
#non_numeric_pk_order ⇒ Symbol
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.
411 412 413 |
# File 'lib/mcp_toolkit/configuration.rb', line 411 def non_numeric_pk_order @non_numeric_pk_order end |
#oauth_allow_loopback_redirects ⇒ Boolean
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
219 220 221 |
# File 'lib/mcp_toolkit/configuration.rb', line 219 def oauth_allow_loopback_redirects @oauth_allow_loopback_redirects end |
#oauth_allowed_redirect_uris ⇒ Array<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"]
161 162 163 |
# File 'lib/mcp_toolkit/configuration.rb', line 161 def oauth_allowed_redirect_uris @oauth_allowed_redirect_uris end |
#oauth_authorization_code_ttl ⇒ Integer
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.
239 240 241 |
# File 'lib/mcp_toolkit/configuration.rb', line 239 def @oauth_authorization_code_ttl end |
#oauth_parent_controller ⇒ String
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).
321 322 323 |
# File 'lib/mcp_toolkit/configuration.rb', line 321 def oauth_parent_controller @oauth_parent_controller end |
#oauth_resource_path ⇒ String
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.
232 233 234 |
# File 'lib/mcp_toolkit/configuration.rb', line 232 def oauth_resource_path @oauth_resource_path end |
#parent_controller ⇒ String
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".
489 490 491 |
# File 'lib/mcp_toolkit/configuration.rb', line 489 def parent_controller @parent_controller end |
#protocol_version ⇒ String?
Returns 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_requests ⇒ Integer?
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.
349 350 351 |
# File 'lib/mcp_toolkit/configuration.rb', line 349 def rate_limit_max_requests @rate_limit_max_requests end |
#rate_limit_window ⇒ Integer
The fixed rate-limit window, in seconds (default 3600 = 1 hour). Ignored
while rate_limit_max_requests is nil.
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.
543 544 545 |
# File 'lib/mcp_toolkit/configuration.rb', line 543 def rate_limiter @rate_limiter end |
#registry ⇒ McpToolkit::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_base ⇒ Object
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_instructions ⇒ String?
Returns human-readable instructions returned on initialize.
26 27 28 |
# File 'lib/mcp_toolkit/configuration.rb', line 26 def server_instructions @server_instructions end |
#server_name ⇒ String
Returns 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_version ⇒ String
Returns 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.
565 566 567 |
# File 'lib/mcp_toolkit/configuration.rb', line 565 def session_data_builder @session_data_builder end |
#session_ttl ⇒ Integer
Returns 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.
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.
368 369 370 |
# File 'lib/mcp_toolkit/configuration.rb', line 368 def superuser_resolver @superuser_resolver end |
#supported_protocol_versions ⇒ Array<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.
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.
113 114 115 |
# File 'lib/mcp_toolkit/configuration.rb', line 113 def token_authenticator @token_authenticator end |
#tool_provider ⇒ Object
587 588 589 |
# File 'lib/mcp_toolkit/configuration.rb', line 587 def tool_provider @tool_provider || composed_tool_provider end |
#upstream_list_ttl ⇒ Integer
Returns 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_timeout ⇒ Integer
Returns 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 |
#upstreams ⇒ McpToolkit::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.
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.
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.
777 778 779 |
# File 'lib/mcp_toolkit/configuration.rb', line 777 def auth_role.to_sym == :authority end |
#initialize_authority_hook_defaults ⇒ Object
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 @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_defaults ⇒ Object
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_defaults ⇒ Object
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_url ⇒ Object
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_path ⇒ String
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.
821 822 823 |
# File 'lib/mcp_toolkit/configuration.rb', line 821 def "#{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.
858 859 860 861 862 863 864 |
# File 'lib/mcp_toolkit/configuration.rb', line 858 def oauth_bridge? return false unless 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.
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_path ⇒ String
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.
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_component ⇒ String
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).
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_secret ⇒ Object
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.
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.
771 772 773 |
# File 'lib/mcp_toolkit/configuration.rb', line 771 def satellite? auth_role.to_sym == :satellite || central_app_url end |