mcp 0.23.0 → 0.24.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/README.md +250 -6
- data/lib/json_rpc_handler.rb +17 -0
- data/lib/mcp/apps.rb +109 -0
- data/lib/mcp/client/elicitation.rb +51 -0
- data/lib/mcp/client/http.rb +326 -31
- data/lib/mcp/client/oauth/client_credentials_provider.rb +72 -14
- data/lib/mcp/client/oauth/flow.rb +148 -5
- data/lib/mcp/client/oauth/in_memory_storage.rb +3 -1
- data/lib/mcp/client/oauth/jwt_client_assertion.rb +128 -0
- data/lib/mcp/client/oauth/provider.rb +11 -3
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/client/paginated_result.rb +7 -5
- data/lib/mcp/client/stdio.rb +3 -1
- data/lib/mcp/client.rb +89 -1
- data/lib/mcp/error_codes.rb +21 -0
- data/lib/mcp/methods.rb +3 -1
- data/lib/mcp/resource.rb +135 -0
- data/lib/mcp/resource_template.rb +149 -0
- data/lib/mcp/result_type.rb +18 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +113 -78
- data/lib/mcp/server.rb +191 -20
- data/lib/mcp/server_context.rb +12 -0
- data/lib/mcp/server_session.rb +40 -0
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +3 -0
- metadata +7 -2
|
@@ -74,13 +74,20 @@ module MCP
|
|
|
74
74
|
)
|
|
75
75
|
|
|
76
76
|
@provider.redirect_handler.call(authorization_url)
|
|
77
|
-
|
|
77
|
+
callback_result = Array(@provider.callback_handler.call)
|
|
78
|
+
code, returned_state, returned_iss = callback_result
|
|
78
79
|
raise AuthorizationError, "Authorization callback did not return an authorization code." unless code
|
|
79
80
|
|
|
80
81
|
unless states_match?(returned_state, state)
|
|
81
82
|
raise AuthorizationError, "OAuth state mismatch (CSRF protection)."
|
|
82
83
|
end
|
|
83
84
|
|
|
85
|
+
validate_authorization_response_issuer!(
|
|
86
|
+
as_metadata: as_metadata,
|
|
87
|
+
iss: returned_iss,
|
|
88
|
+
iss_provided: callback_result.length >= 3,
|
|
89
|
+
)
|
|
90
|
+
|
|
84
91
|
tokens = exchange_authorization_code(
|
|
85
92
|
as_metadata: as_metadata,
|
|
86
93
|
client_info: client_info,
|
|
@@ -101,6 +108,7 @@ module MCP
|
|
|
101
108
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
102
109
|
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
|
|
103
110
|
client_info = client_credentials_client_info
|
|
111
|
+
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
|
|
104
112
|
|
|
105
113
|
form = { "grant_type" => "client_credentials" }
|
|
106
114
|
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
@@ -127,6 +135,25 @@ module MCP
|
|
|
127
135
|
info
|
|
128
136
|
end
|
|
129
137
|
|
|
138
|
+
# Per SEP-2352, static machine-to-machine credentials are bound to their authorization
|
|
139
|
+
# server the same way registered ones are: when the stored `client_information` records
|
|
140
|
+
# an `issuer` that differs from the current authorization server, surface an error
|
|
141
|
+
# instead of silently sending another server's credentials (the spec's "SHOULD surface
|
|
142
|
+
# an error"; the TypeScript SDK raises `AuthorizationServerMismatchError` here).
|
|
143
|
+
# Re-registration is not an option for the `client_credentials` grant - the credentials
|
|
144
|
+
# are pre-registered, not DCR results - so the operator must update the stored credentials.
|
|
145
|
+
# Credentials without a recorded issuer keep working unchanged.
|
|
146
|
+
def ensure_client_credentials_issuer!(client_info, as_metadata:)
|
|
147
|
+
stored_issuer = client_info_required_value(client_info, "issuer")
|
|
148
|
+
return if stored_issuer.nil?
|
|
149
|
+
return if stored_issuer == as_metadata["issuer"]
|
|
150
|
+
|
|
151
|
+
raise AuthorizationError,
|
|
152
|
+
"Stored client credentials are bound to a different authorization server " \
|
|
153
|
+
"(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
|
|
154
|
+
"refusing to send them to the current authorization server (SEP-2352)."
|
|
155
|
+
end
|
|
156
|
+
|
|
130
157
|
# Exchanges the saved `refresh_token` for a fresh access token (RFC 6749 Section 6).
|
|
131
158
|
# Re-discovers PRM and AS metadata so we always pick up a moved token endpoint, and re-runs the audience / issuer / security
|
|
132
159
|
# checks before talking to it.
|
|
@@ -166,6 +193,7 @@ module MCP
|
|
|
166
193
|
client_info = if have_stored_client_info
|
|
167
194
|
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
168
195
|
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
|
|
196
|
+
ensure_refreshable_client_information!(stored_client_info, as_metadata: as_metadata)
|
|
169
197
|
stored_client_info
|
|
170
198
|
elsif as_metadata["client_id_metadata_document_supported"] == true
|
|
171
199
|
{ "client_id" => @provider.client_id_metadata_document_url }
|
|
@@ -412,8 +440,8 @@ module MCP
|
|
|
412
440
|
end
|
|
413
441
|
|
|
414
442
|
def ensure_client_registered(as_metadata:)
|
|
415
|
-
existing =
|
|
416
|
-
return existing if existing
|
|
443
|
+
existing = stored_client_information_for(issuer: as_metadata["issuer"])
|
|
444
|
+
return existing if existing
|
|
417
445
|
|
|
418
446
|
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
|
|
419
447
|
# if the authorization server advertises Client ID Metadata Document support and the provider has
|
|
@@ -462,7 +490,12 @@ module MCP
|
|
|
462
490
|
"Dynamic client registration response is missing `client_id`."
|
|
463
491
|
end
|
|
464
492
|
|
|
465
|
-
|
|
493
|
+
# Per SEP-2352, persisted client credentials are keyed by the issuer identifier of
|
|
494
|
+
# the authorization server that minted them, so a later flow can detect an AS change and
|
|
495
|
+
# re-register instead of replaying another server's credentials. `issuer` is not an RFC 7591 response field;
|
|
496
|
+
# the SDK adds it to the opaque persisted hash.
|
|
497
|
+
@provider.save_client_information(info.merge("issuer" => as_metadata["issuer"]))
|
|
498
|
+
|
|
466
499
|
info
|
|
467
500
|
end
|
|
468
501
|
|
|
@@ -480,6 +513,60 @@ module MCP
|
|
|
480
513
|
metadata.merge("application_type" => Discovery.infer_application_type(redirect_uris))
|
|
481
514
|
end
|
|
482
515
|
|
|
516
|
+
# Returns the stored `client_information` when it may be used against the authorization server
|
|
517
|
+
# identified by `issuer`, applying SEP-2352's authorization server binding rules:
|
|
518
|
+
#
|
|
519
|
+
# - Credentials persisted with an `"issuer"` binding MUST NOT be reused against
|
|
520
|
+
# a different authorization server. When the AS changed, the stale registration and its tokens
|
|
521
|
+
# are discarded (tokens minted by the old AS are dead at the new one) and nil is returned so
|
|
522
|
+
# the flow re-registers.
|
|
523
|
+
# - Stored credentials without an `"issuer"` binding (data persisted by an older SDK version,
|
|
524
|
+
# or user-supplied pre-registered credentials) are bound to the current issuer on first use.
|
|
525
|
+
# - A CIMD `client_id` (an HTTPS URL) is portable across authorization servers, so it is reused
|
|
526
|
+
# and re-bound instead of discarded.
|
|
527
|
+
#
|
|
528
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2352
|
|
529
|
+
def stored_client_information_for(issuer:)
|
|
530
|
+
existing = @provider.client_information
|
|
531
|
+
return unless existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
|
|
532
|
+
|
|
533
|
+
stored_issuer = client_info_required_value(existing, "issuer")
|
|
534
|
+
return existing if stored_issuer == issuer
|
|
535
|
+
return rebind_client_information(existing, issuer: issuer) if stored_issuer.nil?
|
|
536
|
+
|
|
537
|
+
client_id = client_info_required_value(existing, "client_id")
|
|
538
|
+
return rebind_client_information(existing, issuer: issuer) if Discovery.client_id_metadata_document_url?(client_id)
|
|
539
|
+
|
|
540
|
+
@provider.save_client_information(nil)
|
|
541
|
+
@provider.clear_tokens!
|
|
542
|
+
nil
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def rebind_client_information(info, issuer:)
|
|
546
|
+
rebound = info.merge("issuer" => issuer)
|
|
547
|
+
@provider.save_client_information(rebound)
|
|
548
|
+
rebound
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
# Per SEP-2352, stored client credentials are bound to the authorization server that issued them;
|
|
552
|
+
# refuse to replay another AS's credentials at this token endpoint. `HTTP#attempt_refresh` rescues
|
|
553
|
+
# the error and falls back to the full flow, which discards the stale registration and re-registers.
|
|
554
|
+
# Credentials without an `"issuer"` binding predate this check and are allowed through;
|
|
555
|
+
# CIMD `client_id`s are portable.
|
|
556
|
+
def ensure_refreshable_client_information!(client_info, as_metadata:)
|
|
557
|
+
stored_issuer = client_info_required_value(client_info, "issuer")
|
|
558
|
+
return if stored_issuer.nil?
|
|
559
|
+
return if stored_issuer == as_metadata["issuer"]
|
|
560
|
+
|
|
561
|
+
client_id = client_info_required_value(client_info, "client_id")
|
|
562
|
+
return if Discovery.client_id_metadata_document_url?(client_id)
|
|
563
|
+
|
|
564
|
+
raise AuthorizationError,
|
|
565
|
+
"Cannot refresh: stored client credentials were issued by a different authorization server " \
|
|
566
|
+
"(stored issuer #{stored_issuer.inspect}, current #{as_metadata["issuer"].inspect}); " \
|
|
567
|
+
"re-registration is required (SEP-2352)."
|
|
568
|
+
end
|
|
569
|
+
|
|
483
570
|
# Reads `key` from a `client_information` hash that may use either string or
|
|
484
571
|
# symbol keys, so users can persist the result of `JSON.parse` *or* a hand-built
|
|
485
572
|
# `{ client_id:, client_secret: }` and have both work.
|
|
@@ -534,6 +621,43 @@ module MCP
|
|
|
534
621
|
raise AuthorizationError, "#{label} #{url.inspect} is not a valid URI: #{e.message}."
|
|
535
622
|
end
|
|
536
623
|
|
|
624
|
+
# Validates the RFC 9207 `iss` authorization response parameter against the issuer of the authorization server
|
|
625
|
+
# the flow is talking to, per SEP-2468 (mix-up attack mitigation in multi-IdP setups):
|
|
626
|
+
#
|
|
627
|
+
# - When the callback supplied a non-empty `iss`, it MUST equal the AS metadata `issuer` exactly (simple string comparison,
|
|
628
|
+
# no normalization, per RFC 9207 Section 2.4); on mismatch the flow aborts before the authorization code is ever sent to
|
|
629
|
+
# a token endpoint.
|
|
630
|
+
# - When the callback returned a 3-element `[code, state, iss]` whose `iss` is nil (asserting it inspected the authorization response
|
|
631
|
+
# and found no `iss`) and the AS metadata advertises `authorization_response_iss_parameter_supported: true`, the missing parameter
|
|
632
|
+
# is treated as an attack per RFC 9207 Section 2.4 and the flow aborts.
|
|
633
|
+
# - A legacy 2-element `[code, state]` callback skips the check: the caller never looked for `iss`, so its absence carries no signal.
|
|
634
|
+
#
|
|
635
|
+
# `as_metadata["issuer"]` has already been byte-compared against the discovery URL by `ensure_issuer_matches!`,
|
|
636
|
+
# so it is the RFC 9207 anchor value. `iss` is not a secret; a plain `==` suffices.
|
|
637
|
+
#
|
|
638
|
+
# - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2468
|
|
639
|
+
# - https://www.rfc-editor.org/rfc/rfc9207
|
|
640
|
+
def validate_authorization_response_issuer!(as_metadata:, iss:, iss_provided:)
|
|
641
|
+
expected = as_metadata["issuer"]
|
|
642
|
+
|
|
643
|
+
if iss && !iss.to_s.empty?
|
|
644
|
+
return if iss.to_s == expected.to_s
|
|
645
|
+
|
|
646
|
+
raise AuthorizationError,
|
|
647
|
+
"Authorization response `iss` does not match the authorization server issuer " \
|
|
648
|
+
"(expected #{expected.inspect}, got #{iss.inspect}) (RFC 9207); " \
|
|
649
|
+
"refusing to exchange the authorization code."
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
return unless iss_provided
|
|
653
|
+
return unless as_metadata["authorization_response_iss_parameter_supported"] == true
|
|
654
|
+
|
|
655
|
+
raise AuthorizationError,
|
|
656
|
+
"Authorization server advertises `authorization_response_iss_parameter_supported` but " \
|
|
657
|
+
"the authorization response carried no `iss` parameter (RFC 9207 Section 2.4); " \
|
|
658
|
+
"refusing to exchange the authorization code."
|
|
659
|
+
end
|
|
660
|
+
|
|
537
661
|
# Constant-time comparison for the OAuth `state` parameter to prevent timing-based discovery
|
|
538
662
|
# of the expected value.
|
|
539
663
|
# `OpenSSL.fixed_length_secure_compare` would be ideal, but it is not available on Ruby 2.7
|
|
@@ -681,7 +805,26 @@ module MCP
|
|
|
681
805
|
client_secret = client_info_required_value(client_info, "client_secret")
|
|
682
806
|
token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method")
|
|
683
807
|
|
|
684
|
-
form =
|
|
808
|
+
form = if token_endpoint_auth_method == "private_key_jwt"
|
|
809
|
+
# RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of
|
|
810
|
+
# the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046).
|
|
811
|
+
# The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is
|
|
812
|
+
# omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity).
|
|
813
|
+
# The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated.
|
|
814
|
+
unless @provider.respond_to?(:client_assertion)
|
|
815
|
+
raise AuthorizationError,
|
|
816
|
+
"token_endpoint_auth_method is private_key_jwt but the provider does not " \
|
|
817
|
+
"implement `client_assertion(audience:)`."
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
form.merge(
|
|
821
|
+
"client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE,
|
|
822
|
+
"client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]),
|
|
823
|
+
)
|
|
824
|
+
else
|
|
825
|
+
form.merge("client_id" => client_id)
|
|
826
|
+
end
|
|
827
|
+
|
|
685
828
|
headers = {}
|
|
686
829
|
if client_secret
|
|
687
830
|
case token_endpoint_auth_method
|
|
@@ -12,7 +12,9 @@ module MCP
|
|
|
12
12
|
# - `client_information`: the hash returned by Dynamic Client Registration
|
|
13
13
|
# or supplied as pre-registered credentials
|
|
14
14
|
# (`client_id`, optional `client_secret`, optional
|
|
15
|
-
# `token_endpoint_auth_method`).
|
|
15
|
+
# `token_endpoint_auth_method`). The SDK additionally stamps an `"issuer"` member
|
|
16
|
+
# binding the credentials to the authorization server that issued them (SEP-2352);
|
|
17
|
+
# custom storages should treat the hash as opaque and persist it as-is.
|
|
16
18
|
#
|
|
17
19
|
# This class keeps everything in process memory, so the credentials live
|
|
18
20
|
# only for the lifetime of the Ruby process. Applications that need
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module MCP
|
|
9
|
+
class Client
|
|
10
|
+
module OAuth
|
|
11
|
+
# Builds RFC 7523 Section 2.2 JWT client assertions for the `private_key_jwt`
|
|
12
|
+
# client authentication method used by the `client_credentials` grant
|
|
13
|
+
# (MCP extension `io.modelcontextprotocol/oauth-client-credentials`, SEP-1046).
|
|
14
|
+
#
|
|
15
|
+
# The JWS is assembled with openssl so the SDK stays free of a JWT gem dependency
|
|
16
|
+
# (the TypeScript and Python SDKs use jose and PyJWT for the same assertion).
|
|
17
|
+
# Claims follow SEP-1046 and RFC 7523: `iss` and `sub` carry the client_id,
|
|
18
|
+
# `aud` carries the authorization server's issuer identifier, plus `exp`, `iat`,
|
|
19
|
+
# and a unique `jti`.
|
|
20
|
+
#
|
|
21
|
+
# - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046
|
|
22
|
+
# - https://www.rfc-editor.org/rfc/rfc7523#section-2.2
|
|
23
|
+
module JWTClientAssertion
|
|
24
|
+
# Raised when `signing_algorithm` is not supported.
|
|
25
|
+
class UnsupportedAlgorithmError < ArgumentError; end
|
|
26
|
+
|
|
27
|
+
# Raised when the private key cannot be parsed or does not match
|
|
28
|
+
# the requested signing algorithm (e.g. an RSA key with ES256).
|
|
29
|
+
class InvalidKeyError < ArgumentError; end
|
|
30
|
+
|
|
31
|
+
# RFC 7523 Section 2.2 `client_assertion_type` value.
|
|
32
|
+
ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
|
33
|
+
|
|
34
|
+
# Assertion lifetime in seconds; matches the TypeScript SDK's `jwtLifetimeSeconds`
|
|
35
|
+
# and the Python SDK's `lifetime_seconds` default.
|
|
36
|
+
DEFAULT_LIFETIME = 300
|
|
37
|
+
|
|
38
|
+
# ES256 produces a raw `r || s` JWS signature of two 32-byte integers.
|
|
39
|
+
ES256_COMPONENT_BYTES = 32
|
|
40
|
+
private_constant :ES256_COMPONENT_BYTES
|
|
41
|
+
|
|
42
|
+
SUPPORTED_ALGORITHMS = ["ES256", "RS256"].freeze
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
# Returns a signed compact-serialization JWT (`header.payload.signature`).
|
|
46
|
+
#
|
|
47
|
+
# @param client_id [String] The pre-registered OAuth client identifier.
|
|
48
|
+
# @param audience [String] The authorization server's issuer identifier.
|
|
49
|
+
# @param private_key [String, OpenSSL::PKey::PKey] PEM string (PKCS#8 or
|
|
50
|
+
# traditional encoding) or an already-parsed key.
|
|
51
|
+
# @param signing_algorithm [String] `"ES256"` (prime256v1 EC key) or
|
|
52
|
+
# `"RS256"` (RSA key).
|
|
53
|
+
# @param lifetime [Integer] Seconds until the `exp` claim expires.
|
|
54
|
+
def generate(client_id:, audience:, private_key:, signing_algorithm:, lifetime: DEFAULT_LIFETIME)
|
|
55
|
+
unless SUPPORTED_ALGORITHMS.include?(signing_algorithm)
|
|
56
|
+
raise UnsupportedAlgorithmError,
|
|
57
|
+
"signing_algorithm must be one of #{SUPPORTED_ALGORITHMS.inspect} (got #{signing_algorithm.inspect})."
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
key = parse_key(private_key)
|
|
61
|
+
validate_key!(key, signing_algorithm)
|
|
62
|
+
|
|
63
|
+
now = Time.now.to_i
|
|
64
|
+
header = { alg: signing_algorithm, typ: "JWT" }
|
|
65
|
+
claims = {
|
|
66
|
+
iss: client_id,
|
|
67
|
+
sub: client_id,
|
|
68
|
+
aud: audience,
|
|
69
|
+
exp: now + lifetime,
|
|
70
|
+
iat: now,
|
|
71
|
+
jti: SecureRandom.uuid,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
signing_input = "#{base64url(JSON.generate(header))}.#{base64url(JSON.generate(claims))}"
|
|
75
|
+
"#{signing_input}.#{base64url(sign(key, signing_algorithm, signing_input))}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def parse_key(private_key)
|
|
81
|
+
return private_key if private_key.is_a?(OpenSSL::PKey::PKey)
|
|
82
|
+
|
|
83
|
+
OpenSSL::PKey.read(private_key.to_s)
|
|
84
|
+
rescue OpenSSL::PKey::PKeyError => e
|
|
85
|
+
raise InvalidKeyError, "private_key could not be parsed as a PEM-encoded key: #{e.message}."
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_key!(key, signing_algorithm)
|
|
89
|
+
case signing_algorithm
|
|
90
|
+
when "ES256"
|
|
91
|
+
unless key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == "prime256v1"
|
|
92
|
+
raise InvalidKeyError, "ES256 requires an EC private key on the prime256v1 (P-256) curve."
|
|
93
|
+
end
|
|
94
|
+
when "RS256"
|
|
95
|
+
unless key.is_a?(OpenSSL::PKey::RSA)
|
|
96
|
+
raise InvalidKeyError, "RS256 requires an RSA private key."
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
return if key.private?
|
|
101
|
+
|
|
102
|
+
raise InvalidKeyError, "private_key must contain the private component to sign assertions."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def sign(key, signing_algorithm, signing_input)
|
|
106
|
+
der = key.sign(OpenSSL::Digest.new("SHA256"), signing_input)
|
|
107
|
+
return der unless signing_algorithm == "ES256"
|
|
108
|
+
|
|
109
|
+
ecdsa_der_to_raw(der)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# `OpenSSL::PKey::EC#sign` returns an ASN.1 DER `SEQUENCE { r, s }`,
|
|
113
|
+
# while JWS ES256 (RFC 7518 Section 3.4) requires the raw 64-byte
|
|
114
|
+
# `r || s` concatenation with each integer left-padded to 32 bytes.
|
|
115
|
+
def ecdsa_der_to_raw(der)
|
|
116
|
+
OpenSSL::ASN1.decode(der).value.map do |integer|
|
|
117
|
+
integer.value.to_s(16).rjust(ES256_COMPONENT_BYTES * 2, "0")
|
|
118
|
+
end.join.then { |hex| [hex].pack("H*") }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def base64url(data)
|
|
122
|
+
Base64.urlsafe_encode64(data, padding: false)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -21,15 +21,23 @@ module MCP
|
|
|
21
21
|
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
|
22
22
|
# URL (a `URI`). Implementations typically open the user's browser.
|
|
23
23
|
# - `callback_handler` - Callable invoked after `redirect_handler`. Returns
|
|
24
|
-
# `[code, state]` where `code` is the authorization code
|
|
25
|
-
# the `state` parameter received on the redirect URI
|
|
24
|
+
# `[code, state]` or `[code, state, iss]`, where `code` is the authorization code,
|
|
25
|
+
# `state` is the `state` parameter received on the redirect URI, and `iss` is
|
|
26
|
+
# the RFC 9207 `iss` parameter (or nil when the authorization response carried none).
|
|
27
|
+
# Returning the 3-element form opts into SEP-2468 issuer validation: a present `iss`
|
|
28
|
+
# must match the authorization server's issuer, and a nil `iss` is
|
|
29
|
+
# rejected when the AS advertises `authorization_response_iss_parameter_supported`.
|
|
30
|
+
# The 2-element form skips the check for backward compatibility.
|
|
26
31
|
#
|
|
27
32
|
# Optional keyword arguments:
|
|
28
33
|
# - `scope` - String of space-separated scopes to request when the server's
|
|
29
34
|
# `WWW-Authenticate` does not specify one.
|
|
30
35
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
31
36
|
# `client_information`, and `save_client_information(info)`. Defaults to
|
|
32
|
-
# an `InMemoryStorage`.
|
|
37
|
+
# an `InMemoryStorage`. Persisted `client_information` is stamped with
|
|
38
|
+
# an `"issuer"` member binding it to the authorization server that
|
|
39
|
+
# issued it (SEP-2352); when the authorization server changes, the SDK discards
|
|
40
|
+
# the stale registration and tokens and re-registers.
|
|
33
41
|
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
|
|
34
42
|
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
|
|
35
43
|
# When the authorization server advertises `client_id_metadata_document_supported: true`,
|
data/lib/mcp/client/oauth.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "oauth/flow"
|
|
|
5
5
|
require_relative "oauth/in_memory_storage"
|
|
6
6
|
require_relative "oauth/pkce"
|
|
7
7
|
require_relative "oauth/storage_backed_provider"
|
|
8
|
+
require_relative "oauth/jwt_client_assertion"
|
|
8
9
|
require_relative "oauth/provider"
|
|
9
10
|
require_relative "oauth/client_credentials_provider"
|
|
10
11
|
|
|
@@ -4,10 +4,12 @@ module MCP
|
|
|
4
4
|
class Client
|
|
5
5
|
# Result objects returned by `list_tools`, `list_prompts`, `list_resources`, and `list_resource_templates`.
|
|
6
6
|
# Each carries the page items, an optional opaque `next_cursor` string for continuing pagination,
|
|
7
|
-
#
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
# an optional `meta` hash mirroring the MCP `_meta` response field, and the optional SEP-2549
|
|
8
|
+
# cache hints `ttl_ms` (freshness lifetime in milliseconds; 0 means do not cache) and
|
|
9
|
+
# `cache_scope` (`"public"` or `"private"`) mirroring the `ttlMs`/`cacheScope` response fields.
|
|
10
|
+
ListToolsResult = Struct.new(:tools, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
|
11
|
+
ListPromptsResult = Struct.new(:prompts, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
|
12
|
+
ListResourcesResult = Struct.new(:resources, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
|
13
|
+
ListResourceTemplatesResult = Struct.new(:resource_templates, :next_cursor, :meta, :ttl_ms, :cache_scope, keyword_init: true)
|
|
12
14
|
end
|
|
13
15
|
end
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -259,7 +259,9 @@ module MCP
|
|
|
259
259
|
|
|
260
260
|
parsed = JSON.parse(line.strip)
|
|
261
261
|
|
|
262
|
-
|
|
262
|
+
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
|
|
263
|
+
# the same way as a frame without an id.
|
|
264
|
+
next unless parsed.is_a?(Hash) && parsed.key?("id")
|
|
263
265
|
|
|
264
266
|
return parsed if parsed["id"] == request_id
|
|
265
267
|
end
|
data/lib/mcp/client.rb
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "client/elicitation"
|
|
3
4
|
require_relative "client/oauth"
|
|
4
5
|
require_relative "client/stdio"
|
|
5
6
|
require_relative "client/http"
|
|
6
7
|
require_relative "client/paginated_result"
|
|
7
8
|
require_relative "client/tool"
|
|
9
|
+
require_relative "result_type"
|
|
8
10
|
|
|
9
11
|
module MCP
|
|
10
12
|
class Client
|
|
@@ -29,11 +31,44 @@ module MCP
|
|
|
29
31
|
end
|
|
30
32
|
end
|
|
31
33
|
|
|
34
|
+
# Raised inside a server-to-client request handler (registered via `on_server_request`, e.g. `on_sampling`)
|
|
35
|
+
# to answer the request with a specific JSON-RPC error code instead of the default internal error.
|
|
36
|
+
# Mirrors the TypeScript SDK's `McpError` and the Python SDK's `ErrorData` return: for example,
|
|
37
|
+
# the sampling spec answers a rejected request with code `-1`.
|
|
38
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/client/sampling
|
|
39
|
+
class ServerRequestError < StandardError
|
|
40
|
+
attr_reader :code
|
|
41
|
+
|
|
42
|
+
def initialize(message, code:)
|
|
43
|
+
super(message)
|
|
44
|
+
@code = code
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
32
48
|
# Raised when a server response fails client-side validation, e.g., a success response
|
|
33
49
|
# whose `result` field is missing or has the wrong type. This is distinct from a
|
|
34
50
|
# server-returned JSON-RPC error, which is raised as `ServerError`.
|
|
35
51
|
class ValidationError < StandardError; end
|
|
36
52
|
|
|
53
|
+
# Raised when a server answers with a SEP-2322 Multi Round-Trip `input_required` result instead of
|
|
54
|
+
# a final result. The result is not an error on the wire: it asks the client to fulfill the server's
|
|
55
|
+
# `inputRequests` (a map of id => `{ "method" => ..., "params" => ... }` request objects with
|
|
56
|
+
# `sampling/createMessage`, `roots/list`, or `elicitation/create` shapes) and re-issue
|
|
57
|
+
# the original request with `inputResponses` plus the echoed opaque `requestState`.
|
|
58
|
+
# This SDK does not yet drive that resume loop automatically; callers can inspect `input_requests`
|
|
59
|
+
# and respond manually.
|
|
60
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2322
|
|
61
|
+
class InputRequiredError < StandardError
|
|
62
|
+
attr_reader :input_requests, :request_state, :result
|
|
63
|
+
|
|
64
|
+
def initialize(message, input_requests:, request_state: nil, result: nil)
|
|
65
|
+
super(message)
|
|
66
|
+
@input_requests = input_requests
|
|
67
|
+
@request_state = request_state
|
|
68
|
+
@result = result
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
37
72
|
# Raised when the server responds 404 to a request containing a session ID,
|
|
38
73
|
# indicating the session has expired. Inherits from `RequestHandlerError` for
|
|
39
74
|
# backward compatibility with callers that rescue the generic error. Per spec,
|
|
@@ -134,7 +169,13 @@ module MCP
|
|
|
134
169
|
)
|
|
135
170
|
end
|
|
136
171
|
|
|
137
|
-
ListToolsResult.new(
|
|
172
|
+
ListToolsResult.new(
|
|
173
|
+
tools: tools,
|
|
174
|
+
next_cursor: result["nextCursor"],
|
|
175
|
+
meta: result["_meta"],
|
|
176
|
+
ttl_ms: result["ttlMs"],
|
|
177
|
+
cache_scope: result["cacheScope"],
|
|
178
|
+
)
|
|
138
179
|
end
|
|
139
180
|
|
|
140
181
|
# Returns every tool available on the server. Iterates through all pages automatically
|
|
@@ -175,6 +216,8 @@ module MCP
|
|
|
175
216
|
resources: result["resources"] || [],
|
|
176
217
|
next_cursor: result["nextCursor"],
|
|
177
218
|
meta: result["_meta"],
|
|
219
|
+
ttl_ms: result["ttlMs"],
|
|
220
|
+
cache_scope: result["cacheScope"],
|
|
178
221
|
)
|
|
179
222
|
end
|
|
180
223
|
|
|
@@ -208,6 +251,8 @@ module MCP
|
|
|
208
251
|
resource_templates: result["resourceTemplates"] || [],
|
|
209
252
|
next_cursor: result["nextCursor"],
|
|
210
253
|
meta: result["_meta"],
|
|
254
|
+
ttl_ms: result["ttlMs"],
|
|
255
|
+
cache_scope: result["cacheScope"],
|
|
211
256
|
)
|
|
212
257
|
end
|
|
213
258
|
|
|
@@ -241,6 +286,8 @@ module MCP
|
|
|
241
286
|
prompts: result["prompts"] || [],
|
|
242
287
|
next_cursor: result["nextCursor"],
|
|
243
288
|
meta: result["_meta"],
|
|
289
|
+
ttl_ms: result["ttlMs"],
|
|
290
|
+
cache_scope: result["cacheScope"],
|
|
244
291
|
)
|
|
245
292
|
end
|
|
246
293
|
|
|
@@ -352,6 +399,30 @@ module MCP
|
|
|
352
399
|
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
|
|
353
400
|
end
|
|
354
401
|
|
|
402
|
+
# Registers a handler for `elicitation/create` requests the server sends while one of
|
|
403
|
+
# this client's requests is in flight. The handler receives the request `params`
|
|
404
|
+
# (message and `requestedSchema`, string keys) and must return an `ElicitResult`-shaped Hash:
|
|
405
|
+
# `{ action: "accept" | "decline" | "cancel", content: { ... } }`.
|
|
406
|
+
#
|
|
407
|
+
# Requires a transport that supports server-to-client requests (e.g. `MCP::Client::HTTP`);
|
|
408
|
+
# pass `capabilities: { elicitation: {} }` to `connect` so the server knows it may send them.
|
|
409
|
+
#
|
|
410
|
+
# @example Accept with schema defaults applied (SEP-1034)
|
|
411
|
+
# client.on_elicitation do |params|
|
|
412
|
+
# {
|
|
413
|
+
# action: "accept",
|
|
414
|
+
# content: MCP::Client::Elicitation.apply_defaults(params["requestedSchema"]),
|
|
415
|
+
# }
|
|
416
|
+
# end
|
|
417
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation
|
|
418
|
+
def on_elicitation(&handler)
|
|
419
|
+
unless transport.respond_to?(:on_server_request)
|
|
420
|
+
raise ArgumentError, "The transport does not support server-to-client requests"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
transport.on_server_request(Methods::ELICITATION_CREATE, &handler)
|
|
424
|
+
end
|
|
425
|
+
|
|
355
426
|
# Sends a `ping` request to the server to verify the connection is alive.
|
|
356
427
|
# Per the MCP spec, the server responds with an empty result.
|
|
357
428
|
#
|
|
@@ -422,9 +493,26 @@ module MCP
|
|
|
422
493
|
raise ServerError.new(error["message"], code: error["code"], data: error["data"])
|
|
423
494
|
end
|
|
424
495
|
|
|
496
|
+
raise_on_input_required(response)
|
|
497
|
+
|
|
425
498
|
response
|
|
426
499
|
end
|
|
427
500
|
|
|
501
|
+
# Recognizes a SEP-2322 `input_required` result and raises rather than returning it as if it were a final result.
|
|
502
|
+
# Servers on stable protocol versions never emit `resultType`, so this is a no-op for them.
|
|
503
|
+
def raise_on_input_required(response)
|
|
504
|
+
result = response.is_a?(Hash) ? response["result"] : nil
|
|
505
|
+
return unless result.is_a?(Hash) && result["resultType"] == ResultType::INPUT_REQUIRED
|
|
506
|
+
|
|
507
|
+
raise InputRequiredError.new(
|
|
508
|
+
"Server returned `input_required`; this SDK does not yet resume multi-round-trip requests (SEP-2322). " \
|
|
509
|
+
"Inspect `input_requests` to respond manually.",
|
|
510
|
+
input_requests: result["inputRequests"] || {},
|
|
511
|
+
request_state: result["requestState"],
|
|
512
|
+
result: result,
|
|
513
|
+
)
|
|
514
|
+
end
|
|
515
|
+
|
|
428
516
|
# Generates a fresh JSON-RPC request id for an outgoing request.
|
|
429
517
|
# Ids are an internal concern: the public API never accepts or exposes them, and cancellation is driven through
|
|
430
518
|
# an `MCP::Cancellation` token instead.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
# MCP-specific JSON-RPC error codes, complementing the generic codes in `JsonRpcHandler::ErrorCode`.
|
|
5
|
+
#
|
|
6
|
+
# Both constants below are introduced by the stateless lifecycle of the MCP 2026-07-28 draft (SEP-2575):
|
|
7
|
+
# `UNSUPPORTED_PROTOCOL_VERSION` rejects a request whose `_meta`-carried protocol version the server does not
|
|
8
|
+
# support (`error.data: { supported: [...], requested: "..." }`), and `MISSING_REQUIRED_CLIENT_CAPABILITY`
|
|
9
|
+
# rejects a request that requires a client capability the request did not declare
|
|
10
|
+
# (`error.data: { requiredCapabilities: {...} }`). The SDK exports the vocabulary; it does not raise
|
|
11
|
+
# these codes itself yet.
|
|
12
|
+
#
|
|
13
|
+
# The values come from the spec's MCP-specific error code block, which is allocated sequentially from
|
|
14
|
+
# `-32020` toward `-32099`. `-32020` (`HEADER_MISMATCH`, SEP-2243) precedes the two codes defined here.
|
|
15
|
+
#
|
|
16
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2575
|
|
17
|
+
module ErrorCodes
|
|
18
|
+
MISSING_REQUIRED_CLIENT_CAPABILITY = -32021
|
|
19
|
+
UNSUPPORTED_PROTOCOL_VERSION = -32022
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/mcp/methods.rb
CHANGED
|
@@ -5,6 +5,8 @@ module MCP
|
|
|
5
5
|
INITIALIZE = "initialize"
|
|
6
6
|
PING = "ping"
|
|
7
7
|
LOGGING_SET_LEVEL = "logging/setLevel"
|
|
8
|
+
# Sessionless capability discovery (MCP 2026-07-28 draft, SEP-2575).
|
|
9
|
+
SERVER_DISCOVER = "server/discover"
|
|
8
10
|
|
|
9
11
|
PROMPTS_GET = "prompts/get"
|
|
10
12
|
PROMPTS_LIST = "prompts/list"
|
|
@@ -81,7 +83,7 @@ module MCP
|
|
|
81
83
|
require_capability!(method, capabilities, :sampling)
|
|
82
84
|
when ELICITATION_CREATE
|
|
83
85
|
require_capability!(method, capabilities, :elicitation)
|
|
84
|
-
when INITIALIZE, PING, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
|
|
86
|
+
when INITIALIZE, PING, SERVER_DISCOVER, NOTIFICATIONS_INITIALIZED, NOTIFICATIONS_ROOTS_LIST_CHANGED,
|
|
85
87
|
NOTIFICATIONS_PROGRESS, NOTIFICATIONS_CANCELLED, NOTIFICATIONS_ELICITATION_COMPLETE
|
|
86
88
|
# No specific capability required.
|
|
87
89
|
end
|