mcp_toolkit 0.5.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 +187 -0
- data/README.md +174 -0
- data/app/views/mcp_toolkit/oauth/authorize.html.erb +83 -0
- data/config/routes.rb +21 -0
- data/lib/mcp_toolkit/authority/controller_methods.rb +24 -0
- data/lib/mcp_toolkit/configuration.rb +365 -0
- data/lib/mcp_toolkit/engine.rb +15 -0
- data/lib/mcp_toolkit/engine_controllers.rb +69 -2
- data/lib/mcp_toolkit/oauth/controller_methods.rb +426 -0
- data/lib/mcp_toolkit/oauth.rb +23 -0
- data/lib/mcp_toolkit/version.rb +1 -1
- data/lib/mcp_toolkit.rb +8 -2
- metadata +4 -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/version.rb
CHANGED
data/lib/mcp_toolkit.rb
CHANGED
|
@@ -8,12 +8,15 @@ require "zeitwerk"
|
|
|
8
8
|
# subfiles that happen to touch them:
|
|
9
9
|
#
|
|
10
10
|
# json - JSON.parse / JSON.generate (introspection parse, tools, transport)
|
|
11
|
-
# digest - Digest::SHA256 (introspection cache key)
|
|
11
|
+
# digest - Digest::SHA256 (introspection cache key; OAuth bridge PKCE digest)
|
|
12
12
|
# time - Time.iso8601 / Time.parse (introspection expiry parsing)
|
|
13
|
-
# securerandom - SecureRandom.uuid (Session ids)
|
|
13
|
+
# securerandom - SecureRandom.uuid (Session ids; OAuth bridge codes/client ids)
|
|
14
|
+
# uri - URI.parse / encode_www_form (OAuth bridge redirect construction)
|
|
14
15
|
# mcp - the official MCP SDK (Server wraps it; Tools::Base subclasses MCP::Tool)
|
|
15
16
|
# active_support/concern - Transport::ControllerMethods is an includable concern
|
|
16
17
|
# active_support/cache - the default MemoryStore cache_store
|
|
18
|
+
# active_support/security_utils - constant-time compare (OAuth bridge PKCE)
|
|
19
|
+
# active_support/message_encryptor - encrypts the OAuth bridge's cached code payload
|
|
17
20
|
#
|
|
18
21
|
# Two third-party libs are the exception to the centralize-here rule: each is
|
|
19
22
|
# required alongside its owner file rather than up front.
|
|
@@ -25,9 +28,12 @@ require "json"
|
|
|
25
28
|
require "digest"
|
|
26
29
|
require "time"
|
|
27
30
|
require "securerandom"
|
|
31
|
+
require "uri"
|
|
28
32
|
require "mcp"
|
|
29
33
|
require "active_support/concern"
|
|
30
34
|
require "active_support/cache"
|
|
35
|
+
require "active_support/security_utils"
|
|
36
|
+
require "active_support/message_encryptor"
|
|
31
37
|
|
|
32
38
|
# External dependencies (NOT autoloaded by Zeitwerk — only the gem's own tree is).
|
|
33
39
|
# ActiveSupport's specific core extensions are required up front (rather than full
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp_toolkit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Karol Galanciak
|
|
@@ -98,6 +98,7 @@ files:
|
|
|
98
98
|
- LICENSE.txt
|
|
99
99
|
- README.md
|
|
100
100
|
- Rakefile
|
|
101
|
+
- app/views/mcp_toolkit/oauth/authorize.html.erb
|
|
101
102
|
- config/routes.rb
|
|
102
103
|
- lib/mcp_toolkit.rb
|
|
103
104
|
- lib/mcp_toolkit/auth/authenticator.rb
|
|
@@ -134,6 +135,8 @@ files:
|
|
|
134
135
|
- lib/mcp_toolkit/gateway/upstream_registry.rb
|
|
135
136
|
- lib/mcp_toolkit/get_executor.rb
|
|
136
137
|
- lib/mcp_toolkit/list_executor.rb
|
|
138
|
+
- lib/mcp_toolkit/oauth.rb
|
|
139
|
+
- lib/mcp_toolkit/oauth/controller_methods.rb
|
|
137
140
|
- lib/mcp_toolkit/protocol.rb
|
|
138
141
|
- lib/mcp_toolkit/rate_limiter.rb
|
|
139
142
|
- lib/mcp_toolkit/registry.rb
|