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
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The AUTHORITY-side OAuth 2.1 authorization bridge (routes: config/routes.rb;
|
|
4
|
+
# setup + rationale: README).
|
|
5
|
+
#
|
|
6
|
+
# NOT an identity provider, and reading it as a half-built one will mislead. It
|
|
7
|
+
# mints no credential, stores no client, models no consent, issues no refresh
|
|
8
|
+
# token. It is a standards-shaped envelope around tokens the host ALREADY issues
|
|
9
|
+
# by its own means, for clients that will only authenticate by discovering an
|
|
10
|
+
# authorization server and running a browser flow: the page asks an operator to
|
|
11
|
+
# paste a token they hold, and the `access_token` returned IS that token, verified
|
|
12
|
+
# through the same `config.token_authenticator` the transport uses. Scopes,
|
|
13
|
+
# expiry, revocation and tenancy stay with the host; nothing here widens reach.
|
|
14
|
+
#
|
|
15
|
+
# So the stubs are deliberate, not unfinished: no endpoint reads the `client_id`
|
|
16
|
+
# it hands out (a public client's identifier is self-asserted and gates nothing);
|
|
17
|
+
# pasting a token you already hold IS the grant; and the pasted token's own expiry
|
|
18
|
+
# is the real lifetime, so a client re-runs the flow rather than refreshing a
|
|
19
|
+
# shadow of it.
|
|
20
|
+
#
|
|
21
|
+
# Two things are NOT mocked, because faking them would be a vulnerability rather
|
|
22
|
+
# than a skipped ceremony: `redirect_uri` is checked against the host's policy on
|
|
23
|
+
# BOTH legs (an unvetted REMOTE target is an open redirect handing out
|
|
24
|
+
# authorization codes — see Configuration#oauth_allowed_redirect_uris for the
|
|
25
|
+
# attack it stops), and the PKCE `code_verifier` is verified.
|
|
26
|
+
module McpToolkit::Oauth::ControllerMethods
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
CODE_CACHE_PREFIX = "mcp_toolkit:oauth:code:"
|
|
30
|
+
CODE_BYTES = 32
|
|
31
|
+
|
|
32
|
+
# Query parameters the callback response owns: whatever a client put in its own
|
|
33
|
+
# redirect_uri, these are set by the redirect and not carried over from it.
|
|
34
|
+
RESPONSE_OWNED_QUERY_KEYS = %w[code state].freeze
|
|
35
|
+
|
|
36
|
+
# RFC 7636 §4.1: 43–128 unreserved characters. The challenge is §4.2's
|
|
37
|
+
# base64url of a SHA-256, which is always exactly 43 of the same alphabet.
|
|
38
|
+
PKCE_VALUE = /\A[A-Za-z0-9\-._~]{43,128}\z/
|
|
39
|
+
|
|
40
|
+
included do
|
|
41
|
+
# Safe to disable: the token endpoint is called server-to-server without a CSRF
|
|
42
|
+
# token, and `approve` never acts on ambient authority — it reads no session and
|
|
43
|
+
# no cookie, only a pasted token, redirecting to a host-permitted URI. (The GET
|
|
44
|
+
# leg does SET a session cookie, because `form_tag` emits an authenticity
|
|
45
|
+
# token; nothing ever reads it back.)
|
|
46
|
+
protect_from_forgery with: :null_session if respond_to?(:protect_from_forgery)
|
|
47
|
+
|
|
48
|
+
# A before_action, not a guard clause in each action, because a callback runs
|
|
49
|
+
# even when the action itself does not. `authorize` is a common enough method
|
|
50
|
+
# name that a gem patching ActionController::Base with one would knock this
|
|
51
|
+
# action out of Rails' `action_methods` — and Rails would then serve
|
|
52
|
+
# `authorize.html.erb` by implicit render, skipping a body guard entirely and
|
|
53
|
+
# showing an attacker's `redirect_uri` a paste page. Here the check cannot be
|
|
54
|
+
# routed around.
|
|
55
|
+
# Raises rather than skipping when the parent has no filter chain. Skipping
|
|
56
|
+
# would leave both browser legs unguarded — an open redirect issuing codes to
|
|
57
|
+
# any URI — and it is the same shape as the scheme denylist this branch
|
|
58
|
+
# removed for failing open. A guard that installs itself conditionally is not
|
|
59
|
+
# a guard.
|
|
60
|
+
unless respond_to?(:before_action)
|
|
61
|
+
raise McpToolkit::Errors::ConfigurationError,
|
|
62
|
+
"#{name || "The OAuth bridge's parent"} has no `before_action`; " \
|
|
63
|
+
"config.oauth_parent_controller must be an ActionController with a filter chain."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
before_action :mcp_oauth_validate_request!, only: %i[authorize approve]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# `resource` MUST equal the MCP endpoint URL as the operator typed it into the
|
|
70
|
+
# client, hence derived from the live request origin rather than pinned.
|
|
71
|
+
def protected_resource
|
|
72
|
+
mcp_oauth_forbid_caching
|
|
73
|
+
render json: {
|
|
74
|
+
resource: mcp_oauth_resource_url,
|
|
75
|
+
authorization_servers: [mcp_oauth_issuer],
|
|
76
|
+
bearer_methods_supported: ["header"]
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# S256 because clients send a `code_challenge` regardless; `none` because the
|
|
81
|
+
# clients here are public and unverified.
|
|
82
|
+
def authorization_server
|
|
83
|
+
mcp_oauth_forbid_caching
|
|
84
|
+
render json: {
|
|
85
|
+
issuer: mcp_oauth_issuer,
|
|
86
|
+
authorization_endpoint: mcp_oauth_endpoint_url("authorize"),
|
|
87
|
+
token_endpoint: mcp_oauth_endpoint_url("token"),
|
|
88
|
+
registration_endpoint: mcp_oauth_endpoint_url("register"),
|
|
89
|
+
response_types_supported: ["code"],
|
|
90
|
+
grant_types_supported: ["authorization_code"],
|
|
91
|
+
code_challenge_methods_supported: ["S256"],
|
|
92
|
+
token_endpoint_auth_methods_supported: ["none"]
|
|
93
|
+
}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Stateless: persisting a `client_id` nothing reads would only grow a table of
|
|
97
|
+
# strings the bridge never consults.
|
|
98
|
+
def register
|
|
99
|
+
render json: {
|
|
100
|
+
client_id: SecureRandom.uuid,
|
|
101
|
+
token_endpoint_auth_method: "none",
|
|
102
|
+
grant_types: ["authorization_code"],
|
|
103
|
+
response_types: ["code"]
|
|
104
|
+
}, status: :created
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# `formats: [:html]` because there is only an HTML template and `Accept` picks
|
|
108
|
+
# the format just as a `.json` suffix would — without it, `Accept:
|
|
109
|
+
# application/json` raises MissingTemplate on an unauthenticated endpoint.
|
|
110
|
+
def authorize
|
|
111
|
+
render :authorize, layout: false, formats: [:html]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The token is verified here, not only at exchange, so a typo fails on the page
|
|
115
|
+
# the operator is looking at.
|
|
116
|
+
def approve
|
|
117
|
+
access_token = params[:access_token].to_s
|
|
118
|
+
return mcp_oauth_reject_paste if mcp_oauth_authenticate(access_token).nil?
|
|
119
|
+
|
|
120
|
+
# 303, not Rails' default 302: this POST carried the operator's token in its
|
|
121
|
+
# body, and only 303 unambiguously tells the browser to fetch the redirect
|
|
122
|
+
# target with GET and no body. A 302 leaves re-sending it to the client's
|
|
123
|
+
# discretion, which would hand the token itself to the callback (RFC 9700
|
|
124
|
+
# §4.12).
|
|
125
|
+
redirect_to mcp_oauth_callback_url(mcp_oauth_issue_code(access_token)),
|
|
126
|
+
allow_other_host: true, status: :see_other
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def token
|
|
130
|
+
return mcp_oauth_render_token_error("unsupported_grant_type") unless params[:grant_type] == "authorization_code"
|
|
131
|
+
# Shape first, and BEFORE the code is consumed: a request that could never
|
|
132
|
+
# verify shouldn't cost a legitimate client its code. A well-formed but WRONG
|
|
133
|
+
# verifier still burns it below — that is deliberate, and the only thing
|
|
134
|
+
# stopping someone who intercepted a code from retrying verifiers against it.
|
|
135
|
+
return mcp_oauth_render_token_error("invalid_request") unless mcp_oauth_verifier_well_formed?
|
|
136
|
+
|
|
137
|
+
payload = mcp_oauth_consume_code(params[:code].to_s)
|
|
138
|
+
return mcp_oauth_render_token_error("invalid_grant") if payload.nil?
|
|
139
|
+
return mcp_oauth_render_token_error("invalid_grant") unless mcp_oauth_exchange_valid?(payload)
|
|
140
|
+
|
|
141
|
+
access_token = payload[:access_token].to_s
|
|
142
|
+
return mcp_oauth_render_token_error("invalid_grant") if mcp_oauth_authenticate(access_token).nil?
|
|
143
|
+
|
|
144
|
+
mcp_oauth_forbid_caching
|
|
145
|
+
render json: { access_token:, token_type: "Bearer" }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
def mcp_oauth_config
|
|
151
|
+
McpToolkit.config
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# ---- request validation ---------------------------------------------------
|
|
155
|
+
|
|
156
|
+
# Halts both legs before their action runs. Both problems RENDER rather than
|
|
157
|
+
# bounce an OAuth error back to the caller: a disallowed redirect_uri must never
|
|
158
|
+
# be redirected TO — that is the attack.
|
|
159
|
+
def mcp_oauth_validate_request!
|
|
160
|
+
problem = mcp_oauth_request_problem
|
|
161
|
+
mcp_oauth_render_bad_request(problem) if problem
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def mcp_oauth_request_problem
|
|
165
|
+
return mcp_oauth_reject_redirect_uri unless mcp_oauth_redirect_uri_allowed?
|
|
166
|
+
return "Missing or unsupported PKCE code_challenge." unless mcp_oauth_code_challenge_supported?
|
|
167
|
+
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Exact matching makes a legitimate client rejected over a trailing slash look
|
|
172
|
+
# identical to an attack, so log the offered value (a public callback, never a
|
|
173
|
+
# credential) — otherwise an operator is left guessing what to allowlist.
|
|
174
|
+
def mcp_oauth_reject_redirect_uri
|
|
175
|
+
mcp_oauth_config.logger&.warn(
|
|
176
|
+
"[mcp_toolkit] OAuth authorize rejected: redirect_uri #{params[:redirect_uri].inspect} is not in " \
|
|
177
|
+
"config.oauth_allowed_redirect_uris (#{Array(mcp_oauth_config.oauth_allowed_redirect_uris).inspect}) " \
|
|
178
|
+
"and is not a native-client target permitted by config.oauth_allow_loopback_redirects " \
|
|
179
|
+
"(#{mcp_oauth_config.oauth_allow_loopback_redirects ? "enabled" : "disabled"})"
|
|
180
|
+
)
|
|
181
|
+
"Unregistered redirect_uri."
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Every target must be named exactly, with ONE exception: loopback, whose port
|
|
185
|
+
# cannot be named ahead of time. See `mcp_oauth_loopback_redirect_uri?`.
|
|
186
|
+
def mcp_oauth_redirect_uri_allowed?
|
|
187
|
+
redirect_uri = params[:redirect_uri].to_s
|
|
188
|
+
return false if redirect_uri.empty?
|
|
189
|
+
return true if Array(mcp_oauth_config.oauth_allowed_redirect_uris).include?(redirect_uri)
|
|
190
|
+
|
|
191
|
+
mcp_oauth_loopback_redirect_uri?(redirect_uri)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# RFC 8252 §7.3 loopback — the one target accepted unnamed, and only http(s) to
|
|
195
|
+
# a loopback host: NOT private-use schemes, however local they look. See
|
|
196
|
+
# Configuration#oauth_allow_loopback_redirects for why that line is drawn there.
|
|
197
|
+
#
|
|
198
|
+
# Judged on the PARSED URI, never the string, because `host` is what a browser
|
|
199
|
+
# resolves: `http://127.0.0.1@evil.example/` (userinfo — host is evil.example)
|
|
200
|
+
# and `http://127.0.0.1.evil.example/` are both correctly remote. A fragment is
|
|
201
|
+
# refused because OAuth forbids one on a redirect_uri.
|
|
202
|
+
def mcp_oauth_loopback_redirect_uri?(redirect_uri)
|
|
203
|
+
return false unless mcp_oauth_config.oauth_allow_loopback_redirects
|
|
204
|
+
|
|
205
|
+
uri = mcp_oauth_parse_uri(redirect_uri)
|
|
206
|
+
return false if uri.nil?
|
|
207
|
+
return false unless %w[http https].include?(uri.scheme&.downcase)
|
|
208
|
+
return false unless uri.fragment.nil?
|
|
209
|
+
|
|
210
|
+
mcp_oauth_loopback_host?(uri.host)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def mcp_oauth_parse_uri(value)
|
|
214
|
+
URI.parse(value)
|
|
215
|
+
rescue URI::InvalidURIError
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# `URI` keeps the brackets on an IPv6 literal (`[::1]`); strip them so the
|
|
220
|
+
# literal compares against the bare form clients actually send.
|
|
221
|
+
def mcp_oauth_loopback_host?(host)
|
|
222
|
+
McpToolkit::Oauth.loopback_host?(host)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# RFC 7636 §4.1/§4.2 shapes, not just presence. This cannot make a client's
|
|
226
|
+
# PKCE strong — the challenge is a 43-char digest whatever the verifier was, so
|
|
227
|
+
# a client that chose a one-character verifier is indistinguishable here and has
|
|
228
|
+
# only defeated its own protection. It is enforced because a public gem should
|
|
229
|
+
# not quietly accept what the spec forbids, and because a malformed value can
|
|
230
|
+
# then be refused early rather than after a paste.
|
|
231
|
+
def mcp_oauth_code_challenge_supported?
|
|
232
|
+
params[:code_challenge].to_s.match?(PKCE_VALUE) && params[:code_challenge_method].to_s == "S256"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def mcp_oauth_verifier_well_formed?
|
|
236
|
+
params[:code_verifier].to_s.match?(PKCE_VALUE)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# ---- authorization codes --------------------------------------------------
|
|
240
|
+
|
|
241
|
+
# The whole "authorization server" state: one cache entry, short-lived, bound
|
|
242
|
+
# to the challenge and redirect it was issued for.
|
|
243
|
+
#
|
|
244
|
+
# The entry is keyed by the code's DIGEST and its payload is sealed, so a dump
|
|
245
|
+
# of the store yields nothing on its own. Worth the few lines because what is
|
|
246
|
+
# parked there is not the short-lived credential an authorization server would
|
|
247
|
+
# normally hold: it is the operator's pre-existing, long-lived, full-scope
|
|
248
|
+
# token, in a store a host is told to point at its shared `Rails.cache`.
|
|
249
|
+
def mcp_oauth_issue_code(access_token)
|
|
250
|
+
code = SecureRandom.urlsafe_base64(CODE_BYTES)
|
|
251
|
+
payload = {
|
|
252
|
+
access_token:, code_challenge: params[:code_challenge].to_s, redirect_uri: params[:redirect_uri].to_s
|
|
253
|
+
}
|
|
254
|
+
mcp_oauth_config.cache_store.write(
|
|
255
|
+
mcp_oauth_code_key(code),
|
|
256
|
+
mcp_oauth_encryptor(code).encrypt_and_sign(JSON.generate(payload)),
|
|
257
|
+
expires_in: mcp_oauth_config.oauth_authorization_code_ttl
|
|
258
|
+
)
|
|
259
|
+
code
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Single-use for real: the DELETE decides, not the read. `Cache::Store#delete`
|
|
263
|
+
# answers whether the entry was still there, so of two concurrent redemptions
|
|
264
|
+
# exactly one proceeds. (The race was never exploitable — both would return the
|
|
265
|
+
# same token, and both need the verifier — but the guarantee is cheap to keep
|
|
266
|
+
# honest, and a code is burnt even when the exchange that follows fails.)
|
|
267
|
+
def mcp_oauth_consume_code(code)
|
|
268
|
+
return nil if code.empty?
|
|
269
|
+
|
|
270
|
+
key = mcp_oauth_code_key(code)
|
|
271
|
+
blob = mcp_oauth_config.cache_store.read(key)
|
|
272
|
+
return nil if blob.nil?
|
|
273
|
+
return nil unless mcp_oauth_config.cache_store.delete(key)
|
|
274
|
+
|
|
275
|
+
mcp_oauth_decrypt_payload(code, blob)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def mcp_oauth_decrypt_payload(code, blob)
|
|
279
|
+
JSON.parse(mcp_oauth_encryptor(code).decrypt_and_verify(blob), symbolize_names: true)
|
|
280
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, JSON::ParserError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def mcp_oauth_code_key(code)
|
|
285
|
+
"#{CODE_CACHE_PREFIX}#{Digest::SHA256.hexdigest(code)}"
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# HMAC because two independent inputs are being combined (why the secret is one
|
|
289
|
+
# of them: Configuration#oauth_signing_secret). No password-stretching — both
|
|
290
|
+
# are already high-entropy, so a PBKDF2 run per request would buy nothing.
|
|
291
|
+
#
|
|
292
|
+
# Cipher and serializer are pinned, not inherited: the gem supports
|
|
293
|
+
# ActiveSupport >= 6.1, where both defaults are Rails-configuration-dependent
|
|
294
|
+
# (`:marshal`, and 7.1+'s `:json_allow_marshal`). The payload is already a JSON
|
|
295
|
+
# String, so NullSerializer costs nothing and leaves JSON.parse the only parser
|
|
296
|
+
# this code hands the plaintext to.
|
|
297
|
+
#
|
|
298
|
+
# It does NOT buy immunity from a writable cache: `Cache::Store` marshals the
|
|
299
|
+
# entry itself, so a store that can be written to is a code-execution problem
|
|
300
|
+
# before anything here is reached. This is determinism across the supported
|
|
301
|
+
# range, not a mitigation.
|
|
302
|
+
def mcp_oauth_encryptor(code)
|
|
303
|
+
key = OpenSSL::HMAC.digest("SHA256", mcp_oauth_signing_secret, "#{CODE_CACHE_PREFIX}key:#{code}")
|
|
304
|
+
ActiveSupport::MessageEncryptor.new(
|
|
305
|
+
key, cipher: "aes-256-gcm", serializer: ActiveSupport::MessageEncryptor::NullSerializer
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def mcp_oauth_signing_secret
|
|
310
|
+
mcp_oauth_config.oauth_signing_secret.tap do |secret|
|
|
311
|
+
raise McpToolkit::Errors::ConfigurationError, "oauth_signing_secret is not configured" if secret.to_s.empty?
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def mcp_oauth_exchange_valid?(payload)
|
|
316
|
+
payload[:redirect_uri] == params[:redirect_uri].to_s &&
|
|
317
|
+
mcp_oauth_pkce_valid?(params[:code_verifier].to_s, payload[:code_challenge].to_s)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# S256: base64url(sha256(verifier)), unpadded. Packed rather than via base64 so
|
|
321
|
+
# the gem needs no extra dependency.
|
|
322
|
+
def mcp_oauth_pkce_valid?(verifier, challenge)
|
|
323
|
+
return false if verifier.empty? || challenge.empty?
|
|
324
|
+
|
|
325
|
+
digest = [Digest::SHA256.digest(verifier)].pack("m0").tr("+/", "-_").delete("=")
|
|
326
|
+
ActiveSupport::SecurityUtils.secure_compare(digest, challenge)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# ---- token verification ---------------------------------------------------
|
|
330
|
+
|
|
331
|
+
# The same authenticator the transport authenticates every MCP request with —
|
|
332
|
+
# this bridge introduces no second notion of a valid token.
|
|
333
|
+
def mcp_oauth_authenticate(access_token)
|
|
334
|
+
return nil if access_token.empty?
|
|
335
|
+
|
|
336
|
+
McpToolkit::Auth::Authority.authenticate(access_token, config: mcp_oauth_config)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# ---- urls -----------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
# Deliberately path-ful: a client path-INSERTS this to find the metadata, which
|
|
342
|
+
# is what keeps the bridge off the origin-global bare path (see
|
|
343
|
+
# Configuration#oauth_protected_resource_path). Issuer path == resource path, so
|
|
344
|
+
# a client derives the same URL from either.
|
|
345
|
+
def mcp_oauth_issuer
|
|
346
|
+
mcp_oauth_resource_url
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def mcp_oauth_resource_url
|
|
350
|
+
"#{request.base_url}#{mcp_oauth_config.oauth_resource_path_component}"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def mcp_oauth_endpoint_url(action)
|
|
354
|
+
"#{mcp_oauth_resource_url}/oauth/#{action}"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Sets `code` (and echoes `state`) on the client's redirect_uri, preserving any
|
|
358
|
+
# other query it already carries.
|
|
359
|
+
#
|
|
360
|
+
# SETS, not appends: a loopback redirect_uri is not an exact-matched string, so
|
|
361
|
+
# a caller can put `?code=…` in it themselves. Appending would emit
|
|
362
|
+
# `?code=theirs&code=ours` and leave which one wins to the client's parser.
|
|
363
|
+
# Dropping any inbound `code`/`state` keeps the response OAuth-shaped whatever
|
|
364
|
+
# was passed in.
|
|
365
|
+
#
|
|
366
|
+
# The base is taken as the checked string rather than round-tripped through
|
|
367
|
+
# `URI`, so the host part of what is emitted is byte-identical to what the
|
|
368
|
+
# policy approved. The query IS re-encoded (`?a=1?b=2` normalises to
|
|
369
|
+
# `?a=1%3Fb%3D2`), which is the point — that is where `code` gets stripped.
|
|
370
|
+
def mcp_oauth_callback_url(code)
|
|
371
|
+
redirect_uri = params[:redirect_uri].to_s
|
|
372
|
+
base, _, existing = redirect_uri.partition("?")
|
|
373
|
+
pairs = mcp_oauth_preserved_query_pairs(existing)
|
|
374
|
+
pairs << ["code", code]
|
|
375
|
+
pairs << ["state", params[:state].to_s] if params[:state].present?
|
|
376
|
+
"#{base}?#{URI.encode_www_form(pairs)}"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# A client's own query survives; the two parameters this response owns do not,
|
|
380
|
+
# whoever put them there. The rescue is a backstop, not a designed path: both
|
|
381
|
+
# sanctioned redirect_uris are `URI.parse`d before they reach here, which
|
|
382
|
+
# already refuses what `decode_www_form` would raise on.
|
|
383
|
+
def mcp_oauth_preserved_query_pairs(query)
|
|
384
|
+
return [] if query.empty?
|
|
385
|
+
|
|
386
|
+
URI.decode_www_form(query).reject { |pair| RESPONSE_OWNED_QUERY_KEYS.include?(pair.first) }
|
|
387
|
+
rescue ArgumentError
|
|
388
|
+
[]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# ---- responses ------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
# 422 as an integer, not a symbol: the gemspec pins no Rack floor, and neither
|
|
394
|
+
# symbol spans the range it allows — `:unprocessable_content` raises below Rack
|
|
395
|
+
# 3.1, `:unprocessable_entity` is deprecated above it. A symbol that raises here
|
|
396
|
+
# would turn a mistyped paste into an unauthenticated 500 on the one page whose
|
|
397
|
+
# job is to say "that token isn't valid".
|
|
398
|
+
def mcp_oauth_reject_paste
|
|
399
|
+
@mcp_oauth_error = "That access token is not valid, or it has expired or been revoked."
|
|
400
|
+
render :authorize, layout: false, formats: [:html], status: 422
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
def mcp_oauth_render_bad_request(message)
|
|
404
|
+
render plain: message, status: :bad_request
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Applied to the token response, where RFC 6749 §5.1 makes both headers a MUST
|
|
408
|
+
# for anything carrying a token, and to both metadata documents, where the
|
|
409
|
+
# reason is subtler: they name the `authorization_endpoint` an operator will be
|
|
410
|
+
# sent to and are built from the live request origin (`request.base_url`, which
|
|
411
|
+
# honours `X-Forwarded-Host`), so a shared cache that stored one keyed only by
|
|
412
|
+
# path could serve every client an origin an attacker chose — with the document
|
|
413
|
+
# itself vouching for it. A host MUST pin `config.hosts` — Rails'
|
|
414
|
+
# HostAuthorization then rejects a forged header before it reaches here, but
|
|
415
|
+
# Rails does NOT do that for you: `config.hosts` is populated in development and
|
|
416
|
+
# left EMPTY in production, where an empty list means no checking at all. This
|
|
417
|
+
# header is the half that does not depend on the host getting that right.
|
|
418
|
+
def mcp_oauth_forbid_caching
|
|
419
|
+
response.headers["Cache-Control"] = "no-store"
|
|
420
|
+
response.headers["Pragma"] = "no-cache"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def mcp_oauth_render_token_error(code)
|
|
424
|
+
render json: { error: code }, status: :bad_request
|
|
425
|
+
end
|
|
426
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Namespace for the OAuth authorization bridge (McpToolkit::Oauth::ControllerMethods),
|
|
4
|
+
# and the home of the one policy value both the request path and the config path
|
|
5
|
+
# need to agree on.
|
|
6
|
+
module McpToolkit::Oauth
|
|
7
|
+
# RFC 8252 §7.3 loopback hosts — the only hosts a code may be sent to over
|
|
8
|
+
# cleartext, and the only redirect target accepted without being named. The RFC
|
|
9
|
+
# prefers the IP literals over the name (a name is only as trustworthy as the
|
|
10
|
+
# resolver — §8.3), but real clients use all three, and RFC 6761 has OS
|
|
11
|
+
# resolvers and browsers hardcode `localhost` to loopback.
|
|
12
|
+
#
|
|
13
|
+
# Lives here rather than on the concern because Configuration reads it too, to
|
|
14
|
+
# decide whether an allowlisted `http://` entry is a mistake: cleartext is fine
|
|
15
|
+
# to an address that never leaves the operator's machine, and puts the code on
|
|
16
|
+
# the wire anywhere else. One list, so the two paths cannot drift apart.
|
|
17
|
+
LOOPBACK_HOSTS = ["127.0.0.1", "::1", "localhost"].freeze
|
|
18
|
+
|
|
19
|
+
# `[::1]` arrives bracketed from `URI`; compare against the bare form.
|
|
20
|
+
def self.loopback_host?(host)
|
|
21
|
+
LOOPBACK_HOSTS.include?(host.to_s.downcase.delete_prefix("[").delete_suffix("]"))
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/mcp_toolkit/resource.rb
CHANGED
|
@@ -42,6 +42,8 @@ class McpToolkit::Resource
|
|
|
42
42
|
@superusers_only = false
|
|
43
43
|
@filterable = {}
|
|
44
44
|
@filterable_source = nil
|
|
45
|
+
@filter_requirements = {}
|
|
46
|
+
@filter_requirements_source = nil
|
|
45
47
|
@custom_filters = {}
|
|
46
48
|
@required_permissions_scope = nil
|
|
47
49
|
@extras = {}
|
|
@@ -194,6 +196,35 @@ class McpToolkit::Resource
|
|
|
194
196
|
filterable_columns.keys.sort
|
|
195
197
|
end
|
|
196
198
|
|
|
199
|
+
# Declares companion-key requirements for filter keys: a request-facing key
|
|
200
|
+
# that is only valid when another key is passed alongside it (the canonical
|
|
201
|
+
# case is a polymorphic foreign key, type-ambiguous without its `*_type`):
|
|
202
|
+
#
|
|
203
|
+
# filter_requirements created_by_id: :created_by_type
|
|
204
|
+
#
|
|
205
|
+
# The list executor rejects a filter using the key without its companion, and
|
|
206
|
+
# `resource_schema` surfaces the requirement (`relationships[].filter.requires`)
|
|
207
|
+
# so a client can discover it. The companion key MUST itself be declared
|
|
208
|
+
# `filterable` — otherwise the requirement is unsatisfiable (the executor
|
|
209
|
+
# rejects the companion as an unknown key) while the schema still advertises
|
|
210
|
+
# it. Like `filterable`, accepts a Hash (merged now) OR a callable returning
|
|
211
|
+
# one (resolved lazily on first successful read, then memoized) so a host can
|
|
212
|
+
# derive the map without touching the DB at boot. Read with no arg.
|
|
213
|
+
def filter_requirements(mapping = nil, &block)
|
|
214
|
+
source = block || mapping
|
|
215
|
+
if source.nil?
|
|
216
|
+
resolve_filter_requirements_source!
|
|
217
|
+
return @filter_requirements
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
if source.respond_to?(:call)
|
|
221
|
+
@filter_requirements_source = source
|
|
222
|
+
else
|
|
223
|
+
merge_filter_requirements!(source)
|
|
224
|
+
end
|
|
225
|
+
self
|
|
226
|
+
end
|
|
227
|
+
|
|
197
228
|
# Request-facing filter key (symbol) => backing column (symbol). Consumed by
|
|
198
229
|
# the list executor to build the WHERE clause.
|
|
199
230
|
def filterable_columns
|
|
@@ -229,19 +260,37 @@ class McpToolkit::Resource
|
|
|
229
260
|
|
|
230
261
|
private
|
|
231
262
|
|
|
232
|
-
# Resolves a lazily-provided filterable source (a callable)
|
|
233
|
-
#
|
|
234
|
-
# reads are pure Hash access. This is what keeps a DB-derived map (e.g.
|
|
235
|
-
# `Model.column_names`) out of registration/boot time.
|
|
263
|
+
# Resolves a lazily-provided filterable source (a callable) on the first
|
|
264
|
+
# SUCCESSFUL `filterable_columns` / `filterable_keys` read — then drops it, so
|
|
265
|
+
# later reads are pure Hash access. This is what keeps a DB-derived map (e.g.
|
|
266
|
+
# `Model.column_names`) out of registration/boot time. The source is cleared
|
|
267
|
+
# only AFTER it returns: a raising callable (a transient DB hiccup) stays
|
|
268
|
+
# registered and is retried on the next read, instead of permanently and
|
|
269
|
+
# silently resolving the allowlist to `{}`.
|
|
236
270
|
def resolve_filterable_source!
|
|
237
271
|
return unless @filterable_source
|
|
238
272
|
|
|
239
|
-
|
|
273
|
+
resolved = @filterable_source.call || {}
|
|
240
274
|
@filterable_source = nil
|
|
241
|
-
merge_filterable!(
|
|
275
|
+
merge_filterable!(resolved)
|
|
242
276
|
end
|
|
243
277
|
|
|
244
278
|
def merge_filterable!(mapping)
|
|
245
279
|
mapping.each { |request_key, column| @filterable[request_key.to_sym] = column.to_sym }
|
|
246
280
|
end
|
|
281
|
+
|
|
282
|
+
# Same lazy-resolution contract as the filterable source: resolved on the
|
|
283
|
+
# first successful read, cleared only after the callable returns so a
|
|
284
|
+
# transient failure is retried rather than silently dropping the map.
|
|
285
|
+
def resolve_filter_requirements_source!
|
|
286
|
+
return unless @filter_requirements_source
|
|
287
|
+
|
|
288
|
+
resolved = @filter_requirements_source.call || {}
|
|
289
|
+
@filter_requirements_source = nil
|
|
290
|
+
merge_filter_requirements!(resolved)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def merge_filter_requirements!(mapping)
|
|
294
|
+
mapping.each { |key, required| @filter_requirements[key.to_sym] = required.to_sym }
|
|
295
|
+
end
|
|
247
296
|
end
|