mcp_toolkit 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +418 -0
- data/README.md +174 -0
- data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
- data/config/routes.rb +22 -1
- data/lib/mcp_toolkit/authority/controller_methods.rb +39 -0
- data/lib/mcp_toolkit/authority/registry_tool_provider.rb +9 -10
- data/lib/mcp_toolkit/authority/single_tool_provider.rb +25 -0
- data/lib/mcp_toolkit/authority/tools/base.rb +31 -3
- data/lib/mcp_toolkit/authority/tools/get.rb +2 -1
- data/lib/mcp_toolkit/authority/tools/list.rb +49 -1
- data/lib/mcp_toolkit/authority/tools/resource_schema.rb +10 -2
- data/lib/mcp_toolkit/authority/tools/resources.rb +26 -4
- data/lib/mcp_toolkit/configuration.rb +526 -7
- data/lib/mcp_toolkit/dispatcher.rb +17 -3
- data/lib/mcp_toolkit/engine.rb +20 -2
- data/lib/mcp_toolkit/engine_controllers.rb +80 -5
- data/lib/mcp_toolkit/filtering.rb +168 -23
- data/lib/mcp_toolkit/gateway/aggregator.rb +25 -5
- data/lib/mcp_toolkit/list_executor.rb +48 -4
- data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
- data/lib/mcp_toolkit/oauth.rb +23 -0
- data/lib/mcp_toolkit/resource.rb +55 -6
- data/lib/mcp_toolkit/resource_schema.rb +125 -16
- data/lib/mcp_toolkit/serializer/association_descriptor.rb +11 -0
- data/lib/mcp_toolkit/serializer/target_ref.rb +7 -0
- data/lib/mcp_toolkit/session.rb +2 -2
- data/lib/mcp_toolkit/tool_reference_rewriter.rb +33 -0
- data/lib/mcp_toolkit/tools/authority_base.rb +23 -2
- data/lib/mcp_toolkit/tools/base.rb +23 -3
- data/lib/mcp_toolkit/tools/get.rb +1 -1
- data/lib/mcp_toolkit/tools/list.rb +27 -9
- data/lib/mcp_toolkit/tools/resource_schema.rb +12 -3
- data/lib/mcp_toolkit/tools/resources.rb +30 -7
- data/lib/mcp_toolkit/usage_metering/recorder.rb +28 -2
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +8 -2
- metadata +8 -1
|
@@ -112,6 +112,214 @@ class McpToolkit::Configuration
|
|
|
112
112
|
# @return [#call, nil]
|
|
113
113
|
attr_accessor :token_authenticator
|
|
114
114
|
|
|
115
|
+
# --- auth: OAuth authorization bridge (authority-only) ---------------------
|
|
116
|
+
#
|
|
117
|
+
# An OAuth 2.1 authorization-code + PKCE envelope around the tokens the host
|
|
118
|
+
# ALREADY issues, for hosted MCP clients that will only authenticate by
|
|
119
|
+
# discovering an authorization server and running a browser flow (and that
|
|
120
|
+
# cannot be handed a token in the request URI, which the MCP authorization spec
|
|
121
|
+
# forbids). It authenticates nobody: its authorization page asks the operator
|
|
122
|
+
# to paste an existing access token and hands that same token back. See
|
|
123
|
+
# McpToolkit::Oauth::ControllerMethods.
|
|
124
|
+
|
|
125
|
+
# The exact redirect URIs an authorization code may be handed to — the
|
|
126
|
+
# allowlist a REMOTE client's `redirect_uri` is matched against by exact
|
|
127
|
+
# string. This is the bridge's load-bearing control: without it the authorize
|
|
128
|
+
# endpoint would be an open redirect that emits authorization codes.
|
|
129
|
+
#
|
|
130
|
+
# Why it cannot just be opened up to "any client": the page is served from the
|
|
131
|
+
# host's OWN origin under its own certificate and asks for a live token, so an
|
|
132
|
+
# unvetted `redirect_uri` makes it a credential-phishing page hosted by the host
|
|
133
|
+
# itself — an attacker sends the operator an authorize link carrying the
|
|
134
|
+
# attacker's `code_challenge`, the operator pastes, and the code goes to the
|
|
135
|
+
# attacker, who redeems it with the verifier they chose. PKCE cannot help; they
|
|
136
|
+
# own the verifier. This list IS the redirect-URI registration a real
|
|
137
|
+
# authorization server does, and it is the only thing standing in for it.
|
|
138
|
+
#
|
|
139
|
+
# Be precise about what it does NOT cover, because the boundary is easy to
|
|
140
|
+
# overstate. It binds which URL a code may be sent to — not whose session at
|
|
141
|
+
# that URL receives it. Point it at a MULTI-TENANT client (which is the usual
|
|
142
|
+
# case: a hosted MCP client is one callback shared by every user) and an
|
|
143
|
+
# attacker can still start a flow in their OWN account there, send the operator
|
|
144
|
+
# the resulting authorize link, and have the code land back at that client
|
|
145
|
+
# carrying the attacker's `state`. Whether the operator's token then ends up in
|
|
146
|
+
# the attacker's account is decided entirely by whether the CLIENT binds
|
|
147
|
+
# `state` to the browser session that began the flow (RFC 6819 §4.4.1.7; RFC
|
|
148
|
+
# 9700 §4.7.1) — an authorization server cannot bind a code to a session it
|
|
149
|
+
# never saw, so no amount of consent or authentication here would close it. A
|
|
150
|
+
# real authorization server has exactly the same exposure. Only allowlist
|
|
151
|
+
# clients you believe handle `state` correctly.
|
|
152
|
+
#
|
|
153
|
+
# EMPTY BY DEFAULT. Empty, and with `oauth_allow_loopback_redirects` off,
|
|
154
|
+
# the bridge is DISABLED entirely (see `oauth_bridge?`) — so it cannot be
|
|
155
|
+
# switched on without naming who may receive a code, and a host that wants
|
|
156
|
+
# nothing to do with it sets nothing.
|
|
157
|
+
#
|
|
158
|
+
# c.oauth_allowed_redirect_uris = ["https://client.example/callback"]
|
|
159
|
+
#
|
|
160
|
+
# @return [Array<String>]
|
|
161
|
+
attr_reader :oauth_allowed_redirect_uris
|
|
162
|
+
|
|
163
|
+
# Assigns the allowlist, rejecting an entry the bridge must not, or could not,
|
|
164
|
+
# redirect to. Validated at CONFIG time for the same reason
|
|
165
|
+
# `filter_operator_overrides=` is: the alternative is a request-time failure,
|
|
166
|
+
# and here that failure lands at the worst possible moment — the authorize page
|
|
167
|
+
# renders, the operator pastes a live token, the token is verified, a code is
|
|
168
|
+
# written to the cache, and only THEN does the callback URL turn out to be
|
|
169
|
+
# unusable. A typo would cost the operator their paste.
|
|
170
|
+
#
|
|
171
|
+
# The result is FROZEN, so validation is a gate rather than a suggestion: an
|
|
172
|
+
# `<<` onto the reader would otherwise slip an unchecked entry straight past
|
|
173
|
+
# this method. (`config.oauth_allowed_redirect_uris << "…#frag"` used to work,
|
|
174
|
+
# and a fragment is not a harmless typo — the emitted `…/cb#frag?code=…` puts
|
|
175
|
+
# the code in the browser's HASH, so it never reaches the client's server and
|
|
176
|
+
# is readable by any script on the page.)
|
|
177
|
+
#
|
|
178
|
+
# See `oauth_redirect_uri_problem` for what is rejected and why.
|
|
179
|
+
def oauth_allowed_redirect_uris=(uris)
|
|
180
|
+
entries = Array(uris).map { |uri| uri.to_s.dup.freeze }
|
|
181
|
+
entries.each do |uri|
|
|
182
|
+
problem = oauth_redirect_uri_problem(uri)
|
|
183
|
+
next unless problem
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "oauth_allowed_redirect_uris contains #{uri.inspect}: #{problem}"
|
|
186
|
+
end
|
|
187
|
+
@oauth_allowed_redirect_uris = entries.freeze
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Permits LOOPBACK redirect targets on any port without naming each one:
|
|
191
|
+
# `http://127.0.0.1:54321/cb`, `http://localhost:*/cb`, `http://[::1]:*/cb`.
|
|
192
|
+
#
|
|
193
|
+
# This is the one target that cannot be allowlisted even in principle — an MCP
|
|
194
|
+
# client on an operator's machine listens on an ephemeral port chosen at
|
|
195
|
+
# runtime, so no list could enumerate it (RFC 8252 §7.3 exists for exactly
|
|
196
|
+
# this). And it is safe to accept unnamed for the same reason the list above
|
|
197
|
+
# cannot be opened up: a loopback address resolves on the operator's OWN
|
|
198
|
+
# machine, so the phishing described above — which needs the code to reach a
|
|
199
|
+
# REMOTE attacker — does not work through it.
|
|
200
|
+
#
|
|
201
|
+
# Note what this deliberately does NOT cover: a private-use scheme
|
|
202
|
+
# (`cursor://…`, RFC 8252 §7.1). Those keep the code on the device too, but
|
|
203
|
+
# their redirect URI is a FIXED STRING, so it simply goes in the list above —
|
|
204
|
+
# there is no forcing reason to accept one unnamed. And whole schemes cannot be
|
|
205
|
+
# accepted generically anyway: separating a private-use scheme from a
|
|
206
|
+
# registered network one (`ssh:`, `ldap:`, `gopher:` — each naming a remote
|
|
207
|
+
# host) would mean enumerating the IANA registry, and a denylist of the ones
|
|
208
|
+
# you happened to think of is the shape that fails open.
|
|
209
|
+
#
|
|
210
|
+
# A remote `https://` callback is never covered by this, whatever it is set to.
|
|
211
|
+
#
|
|
212
|
+
# OFF BY DEFAULT: switching it on says "any client on my operators' machines may
|
|
213
|
+
# receive a code", which is a decision, not a default — and so is an opt-in
|
|
214
|
+
# signal in its own right.
|
|
215
|
+
#
|
|
216
|
+
# c.oauth_allow_loopback_redirects = true
|
|
217
|
+
#
|
|
218
|
+
# @return [Boolean]
|
|
219
|
+
attr_accessor :oauth_allow_loopback_redirects
|
|
220
|
+
|
|
221
|
+
# The path McpToolkit::Engine is mounted at, used to build the `resource`
|
|
222
|
+
# identifier, the issuer, the two metadata locations, and the bridge's own
|
|
223
|
+
# endpoint URLs (their origin comes from the live request, so every host name
|
|
224
|
+
# the app answers on works). MUST match the actual mount point, and the
|
|
225
|
+
# `resource` it yields MUST equal the MCP endpoint URL as an operator types it
|
|
226
|
+
# into their client.
|
|
227
|
+
#
|
|
228
|
+
# This path is ALSO what keeps the bridge out of the origin's global namespace
|
|
229
|
+
# — see `oauth_protected_resource_path`.
|
|
230
|
+
#
|
|
231
|
+
# @return [String]
|
|
232
|
+
attr_accessor :oauth_resource_path
|
|
233
|
+
|
|
234
|
+
# Seconds an issued authorization code stays redeemable. Codes are single-use
|
|
235
|
+
# (read-and-deleted at exchange); this only bounds a code that is never
|
|
236
|
+
# redeemed. Short by design — a client exchanges immediately.
|
|
237
|
+
#
|
|
238
|
+
# @return [Integer]
|
|
239
|
+
attr_accessor :oauth_authorization_code_ttl
|
|
240
|
+
|
|
241
|
+
# The server-held secret mixed into the key that seals a cached authorization
|
|
242
|
+
# code's payload (McpToolkit::Oauth::ControllerMethods#mcp_oauth_encryptor).
|
|
243
|
+
#
|
|
244
|
+
# It exists because the code alone must NOT be the key: Rails logs an
|
|
245
|
+
# authorization code twice per flow at INFO, so the logs would otherwise carry
|
|
246
|
+
# the key to the cache entry. This secret never reaches a log or a response, so
|
|
247
|
+
# the cache and the logs together still open nothing without it.
|
|
248
|
+
#
|
|
249
|
+
# Defaults to the Rails app's `secret_key_base` (lazily, so load order and a
|
|
250
|
+
# non-Rails host are both fine), which is exactly the "server-held, in ENV,
|
|
251
|
+
# never logged" property wanted — so a Rails host configures nothing. A
|
|
252
|
+
# non-Rails host MUST set it; the bridge refuses to run without one
|
|
253
|
+
# (`oauth_bridge?`), rather than silently sealing with a weak key.
|
|
254
|
+
#
|
|
255
|
+
# c.oauth_signing_secret = ENV.fetch("MCP_OAUTH_SIGNING_SECRET")
|
|
256
|
+
#
|
|
257
|
+
# Use `fetch`, not `ENV["..."]`: a nil from a missing var falls back to
|
|
258
|
+
# `secret_key_base` silently, leaving a host believing it runs a dedicated
|
|
259
|
+
# secret when it does not.
|
|
260
|
+
#
|
|
261
|
+
# Rotating it invalidates in-flight codes (a 60s window), nothing else — the
|
|
262
|
+
# tokens themselves are the host's and are untouched.
|
|
263
|
+
#
|
|
264
|
+
# @return [String, nil]
|
|
265
|
+
#
|
|
266
|
+
# Validated at assignment, like `oauth_allowed_redirect_uris=` — a non-String
|
|
267
|
+
# here passed `oauth_bridge?`, drew the routes, and then raised a TypeError out
|
|
268
|
+
# of `OpenSSL::HMAC` at request time: after the operator pasted a live token and
|
|
269
|
+
# a code was already cached. That is the exact failure the sibling setter exists
|
|
270
|
+
# to prevent, so it gets the same treatment.
|
|
271
|
+
def oauth_signing_secret=(secret)
|
|
272
|
+
raise ArgumentError, "oauth_signing_secret must be a String, got #{secret.class}" if secret && !secret.is_a?(String)
|
|
273
|
+
|
|
274
|
+
if secret.is_a?(String) && !secret.empty? && secret.bytesize < MINIMUM_SIGNING_SECRET_BYTES
|
|
275
|
+
raise ArgumentError,
|
|
276
|
+
"oauth_signing_secret is #{secret.bytesize} bytes; use at least " \
|
|
277
|
+
"#{MINIMUM_SIGNING_SECRET_BYTES} (a real secret_key_base is 128)"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
@oauth_signing_secret = secret
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Short enough to admit any real secret, long enough to catch a placeholder.
|
|
284
|
+
#
|
|
285
|
+
# Deliberately NOT applied to the `secret_key_base` fallback, which is checked
|
|
286
|
+
# for presence only. The minimum exists to catch a placeholder a host typed into
|
|
287
|
+
# THIS setting; `secret_key_base` is Rails' own, is 128 chars in a real
|
|
288
|
+
# deployment, and is short only in environments where Rails generates a throwaway
|
|
289
|
+
# (a stock test env is ~15 bytes). A genuinely weak `secret_key_base` is an
|
|
290
|
+
# app-wide problem — signed cookies, message verifiers, Active Record encryption
|
|
291
|
+
# — and not this gem's to police from the outside.
|
|
292
|
+
MINIMUM_SIGNING_SECRET_BYTES = 32
|
|
293
|
+
|
|
294
|
+
# Reads the configured secret, else the Rails app's `secret_key_base`. Resolved
|
|
295
|
+
# lazily rather than in the initializer: the gem loads before a Rails app is
|
|
296
|
+
# fully configured, and a non-Rails host has no `Rails` constant at all.
|
|
297
|
+
def oauth_signing_secret
|
|
298
|
+
return @oauth_signing_secret if @oauth_signing_secret
|
|
299
|
+
|
|
300
|
+
return nil unless defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
301
|
+
|
|
302
|
+
::Rails.application.secret_key_base
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# The parent class of the bridge's controller, SEPARATE from
|
|
306
|
+
# `parent_controller` and defaulting to ActionController::Base.
|
|
307
|
+
#
|
|
308
|
+
# They are separate because the two controllers have opposite needs. The MCP
|
|
309
|
+
# transport is a JSON-only endpoint, so a host quite reasonably points
|
|
310
|
+
# `parent_controller` at `ActionController::API` — which cannot render an HTML
|
|
311
|
+
# view. The bridge's authorization page IS an HTML view. Deriving it from
|
|
312
|
+
# `parent_controller` would therefore force a host to weaken its transport's
|
|
313
|
+
# superclass just to switch the bridge on; keeping them apart means enabling
|
|
314
|
+
# the bridge changes nothing about the transport.
|
|
315
|
+
#
|
|
316
|
+
# Point this at your own `ApplicationController` to inherit app branding (the
|
|
317
|
+
# page renders with `layout: false` regardless, so an app layout that needs
|
|
318
|
+
# asset-pipeline context is not pulled in).
|
|
319
|
+
#
|
|
320
|
+
# @return [String]
|
|
321
|
+
attr_accessor :oauth_parent_controller
|
|
322
|
+
|
|
115
323
|
# --- caching ---------------------------------------------------------------
|
|
116
324
|
|
|
117
325
|
# The cache store backing sessions and introspection results. Must satisfy the
|
|
@@ -169,6 +377,94 @@ class McpToolkit::Configuration
|
|
|
169
377
|
# @return [#sanitize_sql_like]
|
|
170
378
|
attr_accessor :sql_sanitizer
|
|
171
379
|
|
|
380
|
+
# NOTE: (all data-path settings below): the list/get executors and the schema
|
|
381
|
+
# builder read the PROCESS-GLOBAL `McpToolkit.config` — a per-instance config
|
|
382
|
+
# bound to a provider affects tool prose only. Configure these globally.
|
|
383
|
+
#
|
|
384
|
+
# How a BARE (non-operator) filter value is interpreted by the list executor:
|
|
385
|
+
#
|
|
386
|
+
# :tokenized (default) — a comma-separated string becomes an IN set, the
|
|
387
|
+
# "null" token (and a JSON null) matches NULL rows, an Array of non-null
|
|
388
|
+
# scalars is an IN set (nil/Hash/nested-Array elements rejected), and an
|
|
389
|
+
# empty string means "no filter".
|
|
390
|
+
# :literal — the value is handed to the WHERE clause verbatim (an op-less
|
|
391
|
+
# Hash is still rejected). This preserves an EXISTING API contract for a
|
|
392
|
+
# host whose pre-gem endpoint matched bare values literally: "a,b" is one
|
|
393
|
+
# literal string, "null" is the literal string, "" matches empty-string
|
|
394
|
+
# rows, an Array (including nil elements) gets the adapter's native IN /
|
|
395
|
+
# OR-IS-NULL handling.
|
|
396
|
+
#
|
|
397
|
+
# Operator conditions ({ op:, value: }) behave identically in both modes.
|
|
398
|
+
#
|
|
399
|
+
# @return [Symbol] :tokenized or :literal
|
|
400
|
+
attr_accessor :bare_filter_value_semantics
|
|
401
|
+
|
|
402
|
+
# Default ordering for a resource whose primary key is NON-numeric (numeric
|
|
403
|
+
# PKs always order by :id):
|
|
404
|
+
#
|
|
405
|
+
# :created_at (default) — ORDER BY created_at, <pk> (chronological pages
|
|
406
|
+
# with a total-order tiebreaker).
|
|
407
|
+
# :primary_key — ORDER BY <pk> only. Preserves an EXISTING API contract for
|
|
408
|
+
# a host whose pre-gem endpoint ordered every list by id.
|
|
409
|
+
#
|
|
410
|
+
# @return [Symbol] :created_at or :primary_key
|
|
411
|
+
attr_accessor :non_numeric_pk_order
|
|
412
|
+
|
|
413
|
+
# Per-column-type overrides for the operator sets advertised by
|
|
414
|
+
# `resource_schema` and enforced by the list executor, merged over
|
|
415
|
+
# McpToolkit::Filtering::OPERATORS_BY_TYPE. Lets a host preserve an EXISTING
|
|
416
|
+
# operator contract exactly — both the schema bytes and which conditions are
|
|
417
|
+
# accepted — e.g. `{ text: %w[eq in], date: %w[eq in] }` for a pre-gem
|
|
418
|
+
# endpoint that never offered comparisons on those types. Empty by default
|
|
419
|
+
# (the gem's own sets apply).
|
|
420
|
+
#
|
|
421
|
+
# @return [Hash{Symbol => Array<String>}]
|
|
422
|
+
attr_reader :filter_operator_overrides
|
|
423
|
+
|
|
424
|
+
# Assigns per-type operator overrides, rejecting any operator the gem cannot
|
|
425
|
+
# safely dispatch. Only McpToolkit::Filtering::AREL_PREDICATIONS map onto an
|
|
426
|
+
# Arel predication that binds/quotes its value; anything else (e.g. "extract")
|
|
427
|
+
# would be public_send to an Arel attribute with the request value passed
|
|
428
|
+
# through VERBATIM — an SQL-injection surface. Fail fast at config time so a
|
|
429
|
+
# typo can never open that door at request time.
|
|
430
|
+
def filter_operator_overrides=(overrides)
|
|
431
|
+
overrides ||= {}
|
|
432
|
+
unless overrides.is_a?(Hash)
|
|
433
|
+
raise ArgumentError,
|
|
434
|
+
"filter_operator_overrides must be a Hash of { column_type => [operator, ...] }, " \
|
|
435
|
+
"got #{overrides.class}"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
overrides.each do |type, operators|
|
|
439
|
+
unsupported = Array(operators).map(&:to_s) - McpToolkit::Filtering::AREL_PREDICATIONS
|
|
440
|
+
next if unsupported.empty?
|
|
441
|
+
|
|
442
|
+
raise ArgumentError,
|
|
443
|
+
"filter_operator_overrides[#{type.inspect}] has unsupported operator(s): " \
|
|
444
|
+
"#{unsupported.join(", ")}. Allowed: #{McpToolkit::Filtering::AREL_PREDICATIONS.join(", ")}."
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
@filter_operator_overrides = overrides
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Caps how many values an IN-set filter may resolve to, and how many operator
|
|
451
|
+
# conditions may be ANDed on a single attribute, so a valid token can't emit
|
|
452
|
+
# an unbounded IN clause / AND-chain (oversized SQL + Arel AST and expensive
|
|
453
|
+
# planning; rate limiting is opt-in via rate_limit_max_requests). Applies to
|
|
454
|
+
# the default :tokenized bare-value semantics and to { op:, value: }
|
|
455
|
+
# conditions. nil disables the cap.
|
|
456
|
+
#
|
|
457
|
+
# @return [Integer, nil]
|
|
458
|
+
attr_accessor :max_filter_values
|
|
459
|
+
|
|
460
|
+
# Caps how many JSON-RPC calls a single POST batch may carry on the authority
|
|
461
|
+
# transport. Rate limiting is per-HTTP-request, so an uncapped batch would let
|
|
462
|
+
# one request fan out unbounded work (N tool executions / N upstream calls)
|
|
463
|
+
# under a single rate-limit tick. nil disables the cap.
|
|
464
|
+
#
|
|
465
|
+
# @return [Integer, nil]
|
|
466
|
+
attr_accessor :max_batch_size
|
|
467
|
+
|
|
172
468
|
# --- protocol / transport --------------------------------------------------
|
|
173
469
|
|
|
174
470
|
# @return [String, nil] protocol version to pin on the underlying MCP::Server.
|
|
@@ -277,11 +573,31 @@ class McpToolkit::Configuration
|
|
|
277
573
|
# where a tool object responds to `#required_permissions_scope` (String|nil, the
|
|
278
574
|
# gem's scope gate) and `#call(context:, **arguments)` (returns Hash|String,
|
|
279
575
|
# which the gem wraps into `{ content: [{ type: "text", text: }] }`). See
|
|
280
|
-
# McpToolkit::Tools::AuthorityBase for a base that satisfies this.
|
|
281
|
-
#
|
|
576
|
+
# McpToolkit::Tools::AuthorityBase for a base that satisfies this.
|
|
577
|
+
#
|
|
578
|
+
# UNSET (the default), the provider is composed automatically: the generic
|
|
579
|
+
# RegistryToolProvider (bound to this config) first, then every entry of
|
|
580
|
+
# `extra_tool_providers` — so a host that only serves the generic tools plus
|
|
581
|
+
# its own bespoke ones needs no provider plumbing at all. Assign explicitly to
|
|
582
|
+
# take full control of the catalog.
|
|
583
|
+
#
|
|
584
|
+
# @return [#tool_definitions, #find]
|
|
585
|
+
attr_writer :tool_provider
|
|
586
|
+
|
|
587
|
+
def tool_provider
|
|
588
|
+
@tool_provider || composed_tool_provider
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# Additional tool providers (or bare TOOL classes, auto-wrapped in a
|
|
592
|
+
# SingleToolProvider) composed AFTER the generic Registry-backed tools when
|
|
593
|
+
# `tool_provider` is not explicitly assigned. The registry provider is always
|
|
594
|
+
# first, so a generic tool name resolves to it; extras only answer their own
|
|
595
|
+
# names.
|
|
596
|
+
#
|
|
597
|
+
# config.extra_tool_providers = [MyApp::Tools::AuditLog]
|
|
282
598
|
#
|
|
283
|
-
# @return [
|
|
284
|
-
attr_accessor :
|
|
599
|
+
# @return [Array<#tool_definitions, Class>]
|
|
600
|
+
attr_accessor :extra_tool_providers
|
|
285
601
|
|
|
286
602
|
# --- generic tool naming ---------------------------------------------------
|
|
287
603
|
|
|
@@ -326,10 +642,10 @@ class McpToolkit::Configuration
|
|
|
326
642
|
|
|
327
643
|
@token_authenticator = nil
|
|
328
644
|
|
|
329
|
-
|
|
330
|
-
@session_ttl = 3600 # 1 hour
|
|
645
|
+
initialize_oauth_bridge_defaults
|
|
331
646
|
|
|
332
|
-
@
|
|
647
|
+
@cache_store = ActiveSupport::Cache::MemoryStore.new
|
|
648
|
+
initialize_data_path_defaults
|
|
333
649
|
|
|
334
650
|
@protocol_version = nil
|
|
335
651
|
@supported_protocol_versions = McpToolkit::Protocol::SUPPORTED_VERSIONS
|
|
@@ -348,6 +664,52 @@ class McpToolkit::Configuration
|
|
|
348
664
|
@upstreams = McpToolkit::Gateway::UpstreamRegistry.new
|
|
349
665
|
end
|
|
350
666
|
|
|
667
|
+
# OAuth bridge defaults. The empty redirect allowlist AND the off native-client
|
|
668
|
+
# switch are jointly what keep the bridge OFF (`oauth_bridge?`), so a host that
|
|
669
|
+
# never configures it is unaffected.
|
|
670
|
+
def initialize_oauth_bridge_defaults
|
|
671
|
+
@oauth_allowed_redirect_uris = []
|
|
672
|
+
@oauth_allow_loopback_redirects = false
|
|
673
|
+
@oauth_resource_path = "/mcp"
|
|
674
|
+
@oauth_authorization_code_ttl = 60
|
|
675
|
+
@oauth_parent_controller = "ActionController::Base"
|
|
676
|
+
@oauth_signing_secret = nil # falls back to Rails' secret_key_base — see the reader
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Session-TTL and list-executor defaults: the :tokenized / :created_at
|
|
680
|
+
# data-path semantics (a host preserving a pre-gem contract overrides these —
|
|
681
|
+
# see each accessor's docs).
|
|
682
|
+
def initialize_data_path_defaults
|
|
683
|
+
@session_ttl = 3600 # 1 hour
|
|
684
|
+
|
|
685
|
+
@sql_sanitizer = McpToolkit::SqlSanitizer.new
|
|
686
|
+
@bare_filter_value_semantics = :tokenized
|
|
687
|
+
@non_numeric_pk_order = :created_at
|
|
688
|
+
@filter_operator_overrides = {}
|
|
689
|
+
@max_filter_values = 500
|
|
690
|
+
@max_batch_size = 50
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# The default authority tool catalog when no explicit `tool_provider` is
|
|
694
|
+
# assigned: the generic Registry-backed tools first (so a generic name always
|
|
695
|
+
# resolves to them; included only when the host registered resources — a pure
|
|
696
|
+
# gateway keeps contributing nothing), then each extra provider — a bare tool
|
|
697
|
+
# CLASS is wrapped in a SingleToolProvider. Built per read (cheap, stateless
|
|
698
|
+
# providers), so a reload that re-assigns `extra_tool_providers` or registers
|
|
699
|
+
# resources takes effect immediately.
|
|
700
|
+
def composed_tool_provider
|
|
701
|
+
providers = []
|
|
702
|
+
providers << McpToolkit::Authority::RegistryToolProvider.new(config: self) if registry.resources.any?
|
|
703
|
+
Array(extra_tool_providers).each do |provider|
|
|
704
|
+
providers << (provider.respond_to?(:tool_definitions) ? provider : McpToolkit::Authority::SingleToolProvider.new(provider))
|
|
705
|
+
end
|
|
706
|
+
return nil if providers.empty?
|
|
707
|
+
return providers.first if providers.one?
|
|
708
|
+
|
|
709
|
+
McpToolkit::Authority::CompositeToolProvider.new(*providers)
|
|
710
|
+
end
|
|
711
|
+
private :composed_tool_provider
|
|
712
|
+
|
|
351
713
|
# The authority transport's injection points all default to nil (a no-op): a
|
|
352
714
|
# pure satellite/gateway never touches them. `rate_limit_window` is the sole
|
|
353
715
|
# non-nil default (the window size only matters once a cap opts in).
|
|
@@ -357,6 +719,7 @@ class McpToolkit::Configuration
|
|
|
357
719
|
@usage_flusher = nil
|
|
358
720
|
@session_data_builder = nil
|
|
359
721
|
@tool_provider = nil
|
|
722
|
+
@extra_tool_providers = []
|
|
360
723
|
@rate_limit_max_requests = nil # nil = rate limiting disabled
|
|
361
724
|
@rate_limit_window = 3600 # 1 hour
|
|
362
725
|
@superuser_resolver = nil # nil = duck-type principal.superuser?
|
|
@@ -372,6 +735,19 @@ class McpToolkit::Configuration
|
|
|
372
735
|
upstreams.register(key:, url:, public_tool_list:)
|
|
373
736
|
end
|
|
374
737
|
|
|
738
|
+
# Declares the gateway's upstreams from an ENV-var map — `{ key => env var
|
|
739
|
+
# name }` — the shape every authority host repeats: resets the registry first
|
|
740
|
+
# (idempotent across code reloads, where the registration typically re-runs in
|
|
741
|
+
# a `to_prepare`), and an upstream whose ENV url is blank is never registered,
|
|
742
|
+
# so an unconfigured environment behaves like no-upstreams. `env` is
|
|
743
|
+
# injectable for tests.
|
|
744
|
+
#
|
|
745
|
+
# config.register_upstreams_from_env("billing" => "BILLING_MCP_URL")
|
|
746
|
+
def register_upstreams_from_env(mapping, env: ENV)
|
|
747
|
+
upstreams.reset!
|
|
748
|
+
mapping.each { |key, env_var| register_upstream(key:, url: env[env_var.to_s]) }
|
|
749
|
+
end
|
|
750
|
+
|
|
375
751
|
# The gateway handshake client name, defaulting to the server identity when the
|
|
376
752
|
# host hasn't split it. Read (not stored) so a `server_name` change before the
|
|
377
753
|
# split is set still flows through.
|
|
@@ -402,6 +778,149 @@ class McpToolkit::Configuration
|
|
|
402
778
|
auth_role.to_sym == :authority
|
|
403
779
|
end
|
|
404
780
|
|
|
781
|
+
# The well-known prefixes the two metadata documents hang off. The resource
|
|
782
|
+
# path is INSERTED after these, never appended to the origin — see
|
|
783
|
+
# `oauth_protected_resource_path`.
|
|
784
|
+
PROTECTED_RESOURCE_WELL_KNOWN = "/.well-known/oauth-protected-resource"
|
|
785
|
+
AUTHORIZATION_SERVER_WELL_KNOWN = "/.well-known/oauth-authorization-server"
|
|
786
|
+
|
|
787
|
+
# `oauth_resource_path` normalized for URL building: no trailing slash, and
|
|
788
|
+
# empty when the MCP endpoint IS the origin root (where there is no path
|
|
789
|
+
# component to insert).
|
|
790
|
+
#
|
|
791
|
+
# @return [String] e.g. "/mcp", or "" for a root-mounted endpoint.
|
|
792
|
+
def oauth_resource_path_component
|
|
793
|
+
path = oauth_resource_path.to_s.chomp("/")
|
|
794
|
+
path == "/" ? "" : path
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# Where the protected-resource metadata (RFC 9728) answers, and where
|
|
798
|
+
# `WWW-Authenticate` points.
|
|
799
|
+
#
|
|
800
|
+
# Path-SCOPED (`/.well-known/oauth-protected-resource/mcp`), never the bare
|
|
801
|
+
# path, because the bare ones are ORIGIN-GLOBAL: they describe the authorization
|
|
802
|
+
# server of the whole origin, which on a host already running an unrelated OAuth
|
|
803
|
+
# provider is that provider's claim to make, not an MCP server's. RFC 8414 §3.1
|
|
804
|
+
# exists for this — "Using path components enables supporting multiple issuers
|
|
805
|
+
# per host" — and MCP's 2025-11-25 authorization spec gives a path-ful issuer no
|
|
806
|
+
# root fallback, so scoping is the correct reading rather than a workaround.
|
|
807
|
+
#
|
|
808
|
+
# A root-mounted endpoint has no path to insert and gets the bare paths, which is
|
|
809
|
+
# correct there: it really is that origin's only authorization server.
|
|
810
|
+
#
|
|
811
|
+
# @return [String]
|
|
812
|
+
def oauth_protected_resource_path
|
|
813
|
+
"#{PROTECTED_RESOURCE_WELL_KNOWN}#{oauth_resource_path_component}"
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
# Where the authorization-server metadata (RFC 8414) answers. Path-inserted for
|
|
817
|
+
# the same reason, and it MUST agree with the issuer: a client constructs this
|
|
818
|
+
# URL from the issuer it was given.
|
|
819
|
+
#
|
|
820
|
+
# @return [String]
|
|
821
|
+
def oauth_authorization_server_path
|
|
822
|
+
"#{AUTHORIZATION_SERVER_WELL_KNOWN}#{oauth_resource_path_component}"
|
|
823
|
+
end
|
|
824
|
+
|
|
825
|
+
# Whether the OAuth authorization bridge is live: its routes are drawn, and the
|
|
826
|
+
# authority transport advertises it on a 401 via `WWW-Authenticate`.
|
|
827
|
+
#
|
|
828
|
+
# Gated on three conditions, each for its own reason.
|
|
829
|
+
#
|
|
830
|
+
# AUTHORITY-ONLY, because the flow hands back a token this app itself
|
|
831
|
+
# authenticates — a satellite's tokens belong to its central app, so there is
|
|
832
|
+
# nothing here for it to authorize against.
|
|
833
|
+
#
|
|
834
|
+
# A `token_authenticator` must be set, because the bridge cannot function
|
|
835
|
+
# without one: it verifies the pasted token through it on both legs. Gated
|
|
836
|
+
# rather than left to fail at request time so a misconfigured host serves no
|
|
837
|
+
# bridge at all, instead of an authorization page that accepts an operator's
|
|
838
|
+
# token and then errors — the sibling introspection endpoint fails safe the
|
|
839
|
+
# same way.
|
|
840
|
+
#
|
|
841
|
+
# An `oauth_signing_secret` must resolve, for the same reason: without one the
|
|
842
|
+
# bridge cannot seal a code's payload, and it must not fall back to sealing
|
|
843
|
+
# with something weaker. A Rails host gets `secret_key_base` for free.
|
|
844
|
+
#
|
|
845
|
+
# And at least one redirect target must be named — an allowlist entry, or the
|
|
846
|
+
# loopback switch — so the bridge cannot be running without a bound answer to
|
|
847
|
+
# "who may receive a code". Both are empty/off by default, which is what makes
|
|
848
|
+
# an unconfigured host byte-identical to one without the bridge.
|
|
849
|
+
#
|
|
850
|
+
# NOT gated on a shared `cache_store`, deliberately, even though a per-worker
|
|
851
|
+
# one breaks the flow (see `oauth_per_process_cache_store?`): a MemoryStore is
|
|
852
|
+
# correct in a single process, `Rails.cache` IS a MemoryStore in a stock
|
|
853
|
+
# development environment, and the gem cannot see the worker count. Gating
|
|
854
|
+
# would make the bridge undevelopable locally to prevent a production mistake,
|
|
855
|
+
# so that one is a loud warning at boot instead.
|
|
856
|
+
#
|
|
857
|
+
# @return [Boolean]
|
|
858
|
+
def oauth_bridge?
|
|
859
|
+
return false unless authority?
|
|
860
|
+
return false if token_authenticator.nil?
|
|
861
|
+
return false if oauth_signing_secret.to_s.empty?
|
|
862
|
+
|
|
863
|
+
Array(oauth_allowed_redirect_uris).any? || !!oauth_allow_loopback_redirects
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
# Why a given allowlist entry must not or cannot receive a code, or nil if it
|
|
867
|
+
# may. A lint over values the HOST wrote, not a boundary against an attacker —
|
|
868
|
+
# which is why naming bad schemes is sound here and would not be at request
|
|
869
|
+
# time: nothing an attacker sends reaches this list, so there is no unlisted
|
|
870
|
+
# scheme for them to slip through. (Request-time policy takes the opposite
|
|
871
|
+
# shape for exactly that reason — see
|
|
872
|
+
# McpToolkit::Oauth::ControllerMethods#mcp_oauth_loopback_redirect_uri?.)
|
|
873
|
+
def oauth_redirect_uri_problem(uri)
|
|
874
|
+
parsed = oauth_parse_redirect_uri(uri)
|
|
875
|
+
return "it is not a valid URI" if parsed.nil?
|
|
876
|
+
|
|
877
|
+
scheme = parsed.scheme&.downcase
|
|
878
|
+
return "it names no scheme" if scheme.nil?
|
|
879
|
+
return "it carries a fragment, which OAuth forbids on a redirect_uri" unless parsed.fragment.nil?
|
|
880
|
+
unless parsed.opaque.nil?
|
|
881
|
+
return "it is opaque (#{scheme}:#{parsed.opaque}) so it cannot carry the code — " \
|
|
882
|
+
"write it with a path, e.g. #{scheme}:/#{parsed.opaque}"
|
|
883
|
+
end
|
|
884
|
+
|
|
885
|
+
oauth_redirect_scheme_problem(scheme, parsed)
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
# Schemes a browser may treat as script or local content. A code handed to one
|
|
889
|
+
# is at best lost and at worst executed; no client legitimately registers one.
|
|
890
|
+
ACTIVE_CONTENT_SCHEMES = %w[javascript data vbscript file blob about view-source].freeze
|
|
891
|
+
|
|
892
|
+
def oauth_redirect_scheme_problem(scheme, parsed)
|
|
893
|
+
if ACTIVE_CONTENT_SCHEMES.include?(scheme)
|
|
894
|
+
return "#{scheme}: is not a redirect target — a browser treats it as script or local content"
|
|
895
|
+
end
|
|
896
|
+
return nil unless scheme == "http"
|
|
897
|
+
# RFC 8252 §7.3: cleartext is fine to a loopback address, which never leaves
|
|
898
|
+
# the operator's machine. Anywhere else it puts the code on the wire.
|
|
899
|
+
return nil if McpToolkit::Oauth.loopback_host?(parsed.host)
|
|
900
|
+
|
|
901
|
+
"it is cleartext http to a remote host, which would put the authorization code on the wire — " \
|
|
902
|
+
"use https (cleartext is only accepted for loopback)"
|
|
903
|
+
end
|
|
904
|
+
|
|
905
|
+
def oauth_parse_redirect_uri(uri)
|
|
906
|
+
URI.parse(uri)
|
|
907
|
+
rescue URI::InvalidURIError
|
|
908
|
+
nil
|
|
909
|
+
end
|
|
910
|
+
|
|
911
|
+
# Whether `cache_store` is per-process, which the bridge cannot survive on a
|
|
912
|
+
# multi-worker deployment: a code written on the worker that ran leg 1 is
|
|
913
|
+
# invisible to the worker that runs leg 2, so the exchange reads nil and
|
|
914
|
+
# answers `invalid_grant` — roughly (N-1)/N of the time, AFTER the operator has
|
|
915
|
+
# pasted a live token, and intermittently enough to read as a fluke rather than
|
|
916
|
+
# a misconfiguration. (It also quietly voids the payload sealing: a per-process
|
|
917
|
+
# store has no snapshot to steal, and its key would be in the same heap.)
|
|
918
|
+
#
|
|
919
|
+
# Fine in one process, which is why this warns rather than gates.
|
|
920
|
+
def oauth_per_process_cache_store?
|
|
921
|
+
cache_store.is_a?(ActiveSupport::Cache::MemoryStore)
|
|
922
|
+
end
|
|
923
|
+
|
|
405
924
|
# Full introspection URL the satellite POSTs to. Raises a clear error if the
|
|
406
925
|
# central URL was never configured.
|
|
407
926
|
def introspect_url
|
|
@@ -42,9 +42,13 @@ class McpToolkit::Dispatcher
|
|
|
42
42
|
config.logger&.error("MCP dispatcher error: #{e.message}\n#{e.backtrace&.join("\n")}")
|
|
43
43
|
return nil unless request.key?("id")
|
|
44
44
|
|
|
45
|
+
# A generic message to the caller: the raw exception text of an UNEXPECTED
|
|
46
|
+
# error (ActiveRecord::StatementInvalid carrying SQL, a NoMethodError naming
|
|
47
|
+
# internal classes, an internal hostname) must not leak. Full detail is in
|
|
48
|
+
# the log line above.
|
|
45
49
|
McpToolkit::Protocol.error_response(
|
|
46
50
|
id: request["id"],
|
|
47
|
-
error: McpToolkit::Protocol::InternalError.new(
|
|
51
|
+
error: McpToolkit::Protocol::InternalError.new("Internal error")
|
|
48
52
|
)
|
|
49
53
|
end
|
|
50
54
|
|
|
@@ -189,8 +193,13 @@ class McpToolkit::Dispatcher
|
|
|
189
193
|
# JSON gives string keys; a tool's `call(context:, **arguments)` needs symbol
|
|
190
194
|
# keys for the keyword splat. Deep-symbolized so nested argument hashes reach
|
|
191
195
|
# the tool in the same shape a symbol-keyed caller would pass.
|
|
196
|
+
#
|
|
197
|
+
# `context` is stripped: a tool is invoked as `call(context:, **arguments)`, and
|
|
198
|
+
# a splatted keyword OVERRIDES the explicit one, so a caller-supplied `context`
|
|
199
|
+
# argument would replace the gem-resolved Authority::Context with attacker JSON
|
|
200
|
+
# (auth-context injection). `context` is never a legitimate tool argument.
|
|
192
201
|
def symbolized_arguments(arguments)
|
|
193
|
-
arguments.to_h.deep_symbolize_keys
|
|
202
|
+
arguments.to_h.deep_symbolize_keys.except(:context)
|
|
194
203
|
end
|
|
195
204
|
|
|
196
205
|
def ensure_tool_scope!(tool)
|
|
@@ -228,7 +237,12 @@ class McpToolkit::Dispatcher
|
|
|
228
237
|
data: error.jsonrpc_error["data"]
|
|
229
238
|
)
|
|
230
239
|
else
|
|
231
|
-
|
|
240
|
+
# A TRANSPORT failure (no upstream JSON-RPC error): its message embeds the
|
|
241
|
+
# internal upstream host:port (Faraday/Net::HTTP "Failed to open TCP
|
|
242
|
+
# connection to <host>:<port>"). The proxy already logged the detail
|
|
243
|
+
# (Gateway::Proxy#log_proxied_failure), so relay only a generic error —
|
|
244
|
+
# never the internal topology — to the caller.
|
|
245
|
+
McpToolkit::Protocol::InternalError.new("Internal error")
|
|
232
246
|
end
|
|
233
247
|
end
|
|
234
248
|
end
|
data/lib/mcp_toolkit/engine.rb
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
# Mountable Rails engine that draws the MCP transport routes plus the authority
|
|
4
4
|
# introspection route (defined in the engine's config/routes.rb so they survive
|
|
5
5
|
# Rails' route reloads) against the gem-provided McpToolkit::ServerController /
|
|
6
|
-
# McpToolkit::TokensController. A satellite mounts it in one line:
|
|
6
|
+
# McpToolkit::TokensController. A satellite OR an authority mounts it in one line:
|
|
7
7
|
#
|
|
8
8
|
# # config/routes.rb
|
|
9
9
|
# mount McpToolkit::Engine => "/mcp"
|
|
10
10
|
#
|
|
11
|
-
#
|
|
11
|
+
# The mounted McpToolkit::ServerController is role-aware (built lazily from
|
|
12
|
+
# config.auth_role): an authority host gets the hand-rolled dispatcher path, a
|
|
13
|
+
# satellite gets the SDK-backed one — see engine_controllers.rb. This yields
|
|
14
|
+
# exactly the endpoints a hand-rolled host declared:
|
|
12
15
|
#
|
|
13
16
|
# POST /mcp -> create (JSON-RPC requests/responses)
|
|
14
17
|
# GET /mcp -> stream (405; no server-initiated SSE)
|
|
@@ -33,4 +36,19 @@ class McpToolkit::Engine < Rails::Engine
|
|
|
33
36
|
# reload so a changed parent (or a reloaded app parent class) takes effect on the
|
|
34
37
|
# next reference. Runs before `:eager_load!`, so the fresh classes exist for it.
|
|
35
38
|
config.to_prepare { McpToolkit.reset_engine_controllers! }
|
|
39
|
+
|
|
40
|
+
# The OAuth bridge takes the operator's live access token in a POST body, and
|
|
41
|
+
# Rails logs request parameters at INFO. Filtering it is therefore the gem's
|
|
42
|
+
# business, not a deployment note: a `rails new` app happens to ship a `:token`
|
|
43
|
+
# entry that covers `access_token` by substring, but that is a host default this
|
|
44
|
+
# gem does not own and an `--api` or hand-rolled host may not have. Additive, so
|
|
45
|
+
# a host's own list is untouched, and harmless when the bridge is off.
|
|
46
|
+
#
|
|
47
|
+
# `code_verifier` is belt-and-braces (a logged verifier is worthless once the
|
|
48
|
+
# code is spent, and the code is burnt on read). `code` is deliberately NOT
|
|
49
|
+
# filtered: it is single-use, useless without the verifier, and matching it
|
|
50
|
+
# would filter every `country_code`/`state_code` a host logs.
|
|
51
|
+
initializer "mcp_toolkit.filter_oauth_parameters" do |app|
|
|
52
|
+
app.config.filter_parameters += %i[access_token code_verifier]
|
|
53
|
+
end
|
|
36
54
|
end
|