mcp 1.3.0 → 1.5.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 +1 -1
- data/lib/mcp/client/http.rb +13 -0
- data/lib/mcp/client/oauth/authorization_request.rb +27 -0
- data/lib/mcp/client/oauth/client_credentials_provider.rb +3 -1
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +2 -1
- data/lib/mcp/client/oauth/flow.rb +100 -11
- data/lib/mcp/client/oauth/provider.rb +3 -1
- data/lib/mcp/client/oauth/storage_backed_provider.rb +7 -0
- data/lib/mcp/client/oauth.rb +1 -0
- data/lib/mcp/client/stdio.rb +28 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +102 -27
- data/lib/mcp/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19daecce4ccef4ae391f54643fc8fca17360af39e58541404d090dab52858db8
|
|
4
|
+
data.tar.gz: 75f1cac8a1b8c89b6a6a067b763751f39184e1846de5cfe317b3f8e3886fc5a0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c1653c778f8e782c1ed7c4b66c538076fdce5f35fa00af054f1c9d06c1e78335a02e91734cc90d6107f8ae089946d5085d56d6fc2eeb27a11c84cc6e3f663737
|
|
7
|
+
data.tar.gz: 29cf1b6ee37eed02b83745ce09731db507f64cb95dcb8376ad154525415807db717c8aebd96257c8261eda19b677317a719e8fbd37e0acf3d55e7d8aa1fd8883
|
data/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Detailed guides are available at https://ruby.sdk.modelcontextprotocol.io.
|
|
|
9
9
|
- Build [MCP servers](https://ruby.sdk.modelcontextprotocol.io/server/) that expose tools, prompts, and resources to any MCP host
|
|
10
10
|
- Build [MCP clients](https://ruby.sdk.modelcontextprotocol.io/client/) that connect to any MCP server, with automatic lifecycle negotiation and OAuth 2.1 authorization
|
|
11
11
|
- Speak every standard transport: stdio and Streamable HTTP (including SSE), with a Rails integration
|
|
12
|
-
- Cover the full protocol surface: server-to-client requests, multi round-trip
|
|
12
|
+
- Cover the full protocol surface: server-to-client requests, multi round-trip requests, notifications, progress, logging, cancellation, completions, and pagination
|
|
13
13
|
|
|
14
14
|
## Installation
|
|
15
15
|
|
data/lib/mcp/client/http.rb
CHANGED
|
@@ -1118,6 +1118,19 @@ module MCP
|
|
|
1118
1118
|
},
|
|
1119
1119
|
}
|
|
1120
1120
|
end
|
|
1121
|
+
elsif message["method"] == MCP::Methods::PING && (message["id"].is_a?(String) || message["id"].is_a?(Numeric))
|
|
1122
|
+
# The ping receiver "MUST respond promptly with an empty response" (MCP ping utility),
|
|
1123
|
+
# so an unhandled ping is answered with the empty result rather than Method not found.
|
|
1124
|
+
# A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
|
|
1125
|
+
# schema validation, so a ping carrying one falls through to Method not found instead.
|
|
1126
|
+
# The default lives here instead of in `@server_request_handlers`, whose `any?` opens
|
|
1127
|
+
# the GET listening stream on connect: a built-in pong must not change listener behavior.
|
|
1128
|
+
# A handler registered via `on_server_request("ping")` still wins through the branch above.
|
|
1129
|
+
{
|
|
1130
|
+
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
1131
|
+
id: message["id"],
|
|
1132
|
+
result: {},
|
|
1133
|
+
}
|
|
1121
1134
|
else
|
|
1122
1135
|
{
|
|
1123
1136
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
module OAuth
|
|
6
|
+
# The decision an `authorization_request_validator` is asked to approve: which authorization server this client is about to send the user to,
|
|
7
|
+
# and with which scopes.
|
|
8
|
+
#
|
|
9
|
+
# Handed over as one object rather than as keyword arguments so that later revisions of the MCP authorization specification can add to it
|
|
10
|
+
# without changing the shape every implementer wrote.
|
|
11
|
+
#
|
|
12
|
+
# Only the named readers are the contract. The positional access `Struct` also provides (`request[0]`, `to_a`, `each`) is not,
|
|
13
|
+
# so the representation can change later.
|
|
14
|
+
AuthorizationRequest = Struct.new(
|
|
15
|
+
# `issuer` of the authorization server selected from the MCP server's Protected Resource Metadata.
|
|
16
|
+
:authorization_server,
|
|
17
|
+
# The scopes about to be requested, as an Array. Chosen by the MCP server, through the `WWW-Authenticate` challenge or `scopes_supported`.
|
|
18
|
+
:scopes,
|
|
19
|
+
# The MCP server URL this client was configured with.
|
|
20
|
+
:server_url,
|
|
21
|
+
# The RFC 8707 `resource` the token is being requested for.
|
|
22
|
+
:resource,
|
|
23
|
+
keyword_init: true,
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -60,7 +60,8 @@ module MCP
|
|
|
60
60
|
private_key: nil,
|
|
61
61
|
signing_algorithm: nil,
|
|
62
62
|
scope: nil,
|
|
63
|
-
storage: nil
|
|
63
|
+
storage: nil,
|
|
64
|
+
authorization_request_validator: nil
|
|
64
65
|
)
|
|
65
66
|
if blank?(client_id)
|
|
66
67
|
raise InvalidCredentialsError, "client_id is required for the client_credentials grant."
|
|
@@ -103,6 +104,7 @@ module MCP
|
|
|
103
104
|
@signing_algorithm = signing_algorithm
|
|
104
105
|
@scope = scope
|
|
105
106
|
@storage = storage || InMemoryStorage.new
|
|
107
|
+
@authorization_request_validator = authorization_request_validator
|
|
106
108
|
@storage.save_client_information(client_information)
|
|
107
109
|
end
|
|
108
110
|
|
|
@@ -35,7 +35,7 @@ module MCP
|
|
|
35
35
|
|
|
36
36
|
attr_reader :scope, :storage
|
|
37
37
|
|
|
38
|
-
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil)
|
|
38
|
+
def initialize(client_id:, client_secret:, assertion_provider:, scope: nil, storage: nil, authorization_request_validator: nil)
|
|
39
39
|
if blank?(client_id)
|
|
40
40
|
raise InvalidConfigurationError, "client_id is required for the jwt-bearer grant."
|
|
41
41
|
end
|
|
@@ -51,6 +51,7 @@ module MCP
|
|
|
51
51
|
@assertion_provider = assertion_provider
|
|
52
52
|
@scope = scope
|
|
53
53
|
@storage = storage || InMemoryStorage.new
|
|
54
|
+
@authorization_request_validator = authorization_request_validator
|
|
54
55
|
@storage.save_client_information(
|
|
55
56
|
"client_id" => client_id,
|
|
56
57
|
"client_secret" => client_secret,
|
|
@@ -23,6 +23,11 @@ module MCP
|
|
|
23
23
|
# should leave the refresh token intact.
|
|
24
24
|
class InvalidGrantError < AuthorizationError; end
|
|
25
25
|
|
|
26
|
+
# Raised when `authorization_request_validator` returns a falsy value. Separate from its parent
|
|
27
|
+
# so that a caller can tell a refusal by its own policy from a network, discovery,
|
|
28
|
+
# or authorization server metadata failure by rescuing a class rather than by matching the message text.
|
|
29
|
+
class AuthorizationRefusedError < AuthorizationError; end
|
|
30
|
+
|
|
26
31
|
def initialize(provider:, http_client_factory: nil)
|
|
27
32
|
@provider = provider
|
|
28
33
|
@http_client_factory = http_client_factory || -> { default_http_client }
|
|
@@ -63,17 +68,22 @@ module MCP
|
|
|
63
68
|
|
|
64
69
|
case provider_authorization_flow
|
|
65
70
|
when :client_credentials
|
|
66
|
-
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
71
|
+
return run_client_credentials!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
|
|
67
72
|
when :jwt_bearer
|
|
68
|
-
return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope)
|
|
73
|
+
return run_jwt_bearer!(as_metadata: as_metadata, prm: prm, resource: resource, scope: scope, server_url: server_url)
|
|
69
74
|
end
|
|
70
75
|
|
|
71
76
|
ensure_pkce_supported!(as_metadata)
|
|
72
77
|
|
|
73
|
-
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
74
|
-
|
|
75
78
|
effective_scope = resolve_scope(scope: scope, prm: prm || {})
|
|
76
79
|
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
80
|
+
|
|
81
|
+
# Asked before registering, not after: a refusal must not leave this client registered at an authorization server
|
|
82
|
+
# the embedding application has just rejected.
|
|
83
|
+
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
|
|
84
|
+
|
|
85
|
+
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
86
|
+
|
|
77
87
|
pkce = PKCE.generate
|
|
78
88
|
state = SecureRandom.urlsafe_base64(32)
|
|
79
89
|
|
|
@@ -109,7 +119,7 @@ module MCP
|
|
|
109
119
|
resource: resource,
|
|
110
120
|
)
|
|
111
121
|
|
|
112
|
-
|
|
122
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
113
123
|
:authorized
|
|
114
124
|
end
|
|
115
125
|
|
|
@@ -119,17 +129,19 @@ module MCP
|
|
|
119
129
|
# and no `offline_access` augmentation because the grant does not issue a refresh token (OAuth 2.1 Section 4.3.3).
|
|
120
130
|
# The pre-registered `client_id` / `client_secret` come from the provider's stored `client_information`.
|
|
121
131
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
122
|
-
def run_client_credentials!(as_metadata:, prm:, resource:, scope:)
|
|
132
|
+
def run_client_credentials!(as_metadata:, prm:, resource:, scope:, server_url:)
|
|
123
133
|
client_info = client_credentials_client_info
|
|
124
134
|
ensure_client_credentials_issuer!(client_info, as_metadata: as_metadata)
|
|
125
135
|
|
|
126
136
|
form = { "grant_type" => "client_credentials" }
|
|
127
137
|
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
138
|
+
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
|
|
128
139
|
form["scope"] = effective_scope if effective_scope
|
|
129
140
|
form["resource"] = resource if resource
|
|
130
141
|
|
|
131
142
|
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
132
|
-
|
|
143
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
144
|
+
|
|
133
145
|
:authorized
|
|
134
146
|
end
|
|
135
147
|
|
|
@@ -173,12 +185,18 @@ module MCP
|
|
|
173
185
|
# and security checks as `run!`; like `client_credentials`, there is no PKCE, redirect, or authorization request.
|
|
174
186
|
# The assertion's audience is the issuer identifier that `ensure_issuer_matches!` validated.
|
|
175
187
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/issues/990
|
|
176
|
-
def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:)
|
|
188
|
+
def run_jwt_bearer!(as_metadata:, prm:, resource:, scope:, server_url:)
|
|
177
189
|
client_info = @provider.client_information
|
|
178
190
|
unless client_info.is_a?(Hash) && client_info_required_value(client_info, "client_id")
|
|
179
191
|
raise AuthorizationError, "Cannot run the jwt-bearer grant: the provider has no stored `client_id`."
|
|
180
192
|
end
|
|
181
193
|
|
|
194
|
+
# Asked before the assertion is minted, for the same reason registration waits on the authorization-code grant:
|
|
195
|
+
# obtaining an ID-JAG sends the identity provider an audience of this authorization server, which must not happen once
|
|
196
|
+
# the host has refused it.
|
|
197
|
+
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
198
|
+
authorize_request!(as_metadata: as_metadata, scope: effective_scope, server_url: server_url, resource: resource)
|
|
199
|
+
|
|
182
200
|
assertion = @provider.jwt_bearer_assertion(audience: as_metadata["issuer"], resource: resource)
|
|
183
201
|
if assertion.nil? || assertion.to_s.empty?
|
|
184
202
|
raise AuthorizationError, "The provider's assertion_provider returned no ID-JAG assertion."
|
|
@@ -188,12 +206,11 @@ module MCP
|
|
|
188
206
|
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
189
207
|
"assertion" => assertion,
|
|
190
208
|
}
|
|
191
|
-
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
192
209
|
form["scope"] = effective_scope if effective_scope
|
|
193
210
|
form["resource"] = resource if resource
|
|
194
211
|
|
|
195
212
|
tokens = post_to_token_endpoint(as_metadata: as_metadata, client_info: client_info, form: form)
|
|
196
|
-
|
|
213
|
+
save_tokens_issued_by(tokens, as_metadata: as_metadata)
|
|
197
214
|
:authorized
|
|
198
215
|
end
|
|
199
216
|
|
|
@@ -242,6 +259,8 @@ module MCP
|
|
|
242
259
|
server_url: server_url,
|
|
243
260
|
)
|
|
244
261
|
|
|
262
|
+
ensure_token_issuer!(as_metadata: as_metadata)
|
|
263
|
+
|
|
245
264
|
client_info = if have_stored_client_info
|
|
246
265
|
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
247
266
|
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
|
|
@@ -262,7 +281,7 @@ module MCP
|
|
|
262
281
|
resource: resource,
|
|
263
282
|
)
|
|
264
283
|
|
|
265
|
-
|
|
284
|
+
save_tokens_issued_by(preserve_refresh_token(new_tokens, refresh_token), as_metadata: as_metadata)
|
|
266
285
|
:refreshed
|
|
267
286
|
end
|
|
268
287
|
|
|
@@ -499,6 +518,43 @@ module MCP
|
|
|
499
518
|
# the authorization server on two loopback ports), and deployments that never leave a corporate network.
|
|
500
519
|
# Only a server reachable on the public internet is barred from steering the client inward.
|
|
501
520
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
521
|
+
# Hands the embedding application the authorization server and the scopes that are about to be requested,
|
|
522
|
+
# and abandons the flow when it refuses them.
|
|
523
|
+
#
|
|
524
|
+
# Both values are chosen by the MCP server: it names its own authorization server in Protected
|
|
525
|
+
# Resource Metadata and states the scopes in `scopes_supported` or the `WWW-Authenticate` challenge.
|
|
526
|
+
# Neither the specification nor any MCP SDK binds that choice to the server's own identity,
|
|
527
|
+
# and validating that a token was issued for the intended audience is a responsibility the specification
|
|
528
|
+
# places on MCP servers rather than on clients.
|
|
529
|
+
# A host that knows which providers its user deals with can apply that knowledge here.
|
|
530
|
+
#
|
|
531
|
+
# The scopes are passed on unchanged whatever the host decides, because the specification requires
|
|
532
|
+
# a client to treat the challenged scopes as authoritative for the operation; the choice offered is
|
|
533
|
+
# to proceed or to stop, not to quietly ask for less. A provider without the hook proceeds as before.
|
|
534
|
+
#
|
|
535
|
+
# Only asked when a new grant is being requested. A refresh is not a new grant, and the host already answered
|
|
536
|
+
# this question for that authorization server, so `refresh!` enforces `ensure_token_issuer!` instead:
|
|
537
|
+
# an authorization server that has changed since the tokens were issued sends the flow back through here,
|
|
538
|
+
# where the host sees the new one and decides again.
|
|
539
|
+
def authorize_request!(as_metadata:, scope:, server_url:, resource:)
|
|
540
|
+
return unless @provider.respond_to?(:authorization_request_validator)
|
|
541
|
+
return unless (validator = @provider.authorization_request_validator)
|
|
542
|
+
|
|
543
|
+
request = AuthorizationRequest.new(
|
|
544
|
+
authorization_server: as_metadata["issuer"],
|
|
545
|
+
scopes: scope.to_s.split,
|
|
546
|
+
server_url: server_url,
|
|
547
|
+
resource: resource,
|
|
548
|
+
).freeze
|
|
549
|
+
|
|
550
|
+
return if validator.call(request)
|
|
551
|
+
|
|
552
|
+
raise AuthorizationRefusedError, <<~MESSAGE
|
|
553
|
+
The authorization request was refused by `authorization_request_validator` \
|
|
554
|
+
(authorization server #{request.authorization_server.inspect}, scopes #{request.scopes.inspect}).
|
|
555
|
+
MESSAGE
|
|
556
|
+
end
|
|
557
|
+
|
|
502
558
|
def ensure_routable_destination!(url, label:, server_url:)
|
|
503
559
|
return unless private_network_url?(url)
|
|
504
560
|
return if private_network_url?(server_url)
|
|
@@ -669,6 +725,39 @@ module MCP
|
|
|
669
725
|
# the error and falls back to the full flow, which discards the stale registration and re-registers.
|
|
670
726
|
# Credentials without an `"issuer"` binding predate this check and are allowed through;
|
|
671
727
|
# CIMD `client_id`s are portable.
|
|
728
|
+
# Records which authorization server minted these tokens, alongside the tokens themselves.
|
|
729
|
+
# SEP-2352 already binds *client credentials* to their issuer; this binds the *tokens*, which that
|
|
730
|
+
# SEP leaves unbound.
|
|
731
|
+
# Stored in the token hash so it travels through any `storage` a caller supplied, without widening
|
|
732
|
+
# the storage interface that custom implementations have to satisfy.
|
|
733
|
+
def save_tokens_issued_by(tokens, as_metadata:)
|
|
734
|
+
issuer = as_metadata["issuer"]
|
|
735
|
+
tokens = tokens.merge("issuer" => issuer) if tokens.is_a?(Hash) && issuer
|
|
736
|
+
|
|
737
|
+
@provider.save_tokens(tokens)
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
# Refuses to present a refresh token to an authorization server other than the one that issued it.
|
|
741
|
+
# `refresh!` rediscovers the authorization server from Protected Resource Metadata every time,
|
|
742
|
+
# so the server named for a session can differ from the one the stored tokens came from.
|
|
743
|
+
# Unlike `ensure_refreshable_client_information!` this holds whatever the client identity is,
|
|
744
|
+
# including a Client ID Metadata Document URL, which is portable across authorization servers precisely
|
|
745
|
+
# so that re-registration is unnecessary.
|
|
746
|
+
#
|
|
747
|
+
# Tokens stored before this shipped carry no issuer and are left alone, matching how SEP-2352 treats
|
|
748
|
+
# client information without one; the binding takes effect from their next authorization.
|
|
749
|
+
# The caller answers a refusal by running the full authorization flow, which is where the embedding application
|
|
750
|
+
# is asked about the new authorization server.
|
|
751
|
+
def ensure_token_issuer!(as_metadata:)
|
|
752
|
+
stored_issuer = read_token("issuer")
|
|
753
|
+
return if stored_issuer.nil? || stored_issuer == as_metadata["issuer"]
|
|
754
|
+
|
|
755
|
+
raise AuthorizationError, <<~MESSAGE
|
|
756
|
+
Cannot refresh: the stored tokens were issued by a different authorization server (stored issuer #{stored_issuer.inspect}, \
|
|
757
|
+
current #{as_metadata["issuer"].inspect}); re-authorization is required.
|
|
758
|
+
MESSAGE
|
|
759
|
+
end
|
|
760
|
+
|
|
672
761
|
def ensure_refreshable_client_information!(client_info, as_metadata:)
|
|
673
762
|
stored_issuer = client_info_required_value(client_info, "issuer")
|
|
674
763
|
return if stored_issuer.nil?
|
|
@@ -86,7 +86,8 @@ module MCP
|
|
|
86
86
|
callback_handler:,
|
|
87
87
|
scope: nil,
|
|
88
88
|
storage: nil,
|
|
89
|
-
client_id_metadata_document_url: nil
|
|
89
|
+
client_id_metadata_document_url: nil,
|
|
90
|
+
authorization_request_validator: nil
|
|
90
91
|
)
|
|
91
92
|
unless Discovery.secure_url?(redirect_uri)
|
|
92
93
|
raise InsecureRedirectURIError,
|
|
@@ -115,6 +116,7 @@ module MCP
|
|
|
115
116
|
@scope = scope
|
|
116
117
|
@storage = storage || InMemoryStorage.new
|
|
117
118
|
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
119
|
+
@authorization_request_validator = authorization_request_validator
|
|
118
120
|
end
|
|
119
121
|
|
|
120
122
|
# Identifies the OAuth flow this provider drives.
|
|
@@ -14,6 +14,13 @@ module MCP
|
|
|
14
14
|
# `save_tokens(tokens)`, `client_information`, and `save_client_information(info)`
|
|
15
15
|
# (see `InMemoryStorage`).
|
|
16
16
|
module StorageBackedProvider
|
|
17
|
+
# Optional `->(request) { true | false }` hook, called with an `AuthorizationRequest`.
|
|
18
|
+
# An MCP server names its own authorization server and the scopes to ask for, so this is where
|
|
19
|
+
# an embedding application sees both before the request is made and can decline to proceed.
|
|
20
|
+
# `nil` (the default) authorizes whatever the server asked for, which is the behavior every
|
|
21
|
+
# MCP SDK has today.
|
|
22
|
+
attr_reader :authorization_request_validator
|
|
23
|
+
|
|
17
24
|
def access_token
|
|
18
25
|
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
|
19
26
|
end
|
data/lib/mcp/client/oauth.rb
CHANGED
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -436,6 +436,18 @@ module MCP
|
|
|
436
436
|
|
|
437
437
|
parsed = JSON.parse(line.strip)
|
|
438
438
|
|
|
439
|
+
# A frame carrying `method` is a request or notification, never the awaited response,
|
|
440
|
+
# whatever its id says: JSON-RPC id spaces are per sender, so a server-chosen ping id
|
|
441
|
+
# may legitimately collide with the awaited one. Pings are answered;
|
|
442
|
+
# other server-to-client requests over stdio stay unsupported as documented,
|
|
443
|
+
# and notifications carry no id.
|
|
444
|
+
if parsed.is_a?(Hash) && parsed.key?("method")
|
|
445
|
+
# A JSON-RPC id is a String or a Number; the reference SDKs reject other shapes at
|
|
446
|
+
# schema validation, so a ping carrying one is skipped rather than echoed back.
|
|
447
|
+
answer_ping(parsed) if parsed["method"] == MCP::Methods::PING && json_rpc_id?(parsed["id"])
|
|
448
|
+
next
|
|
449
|
+
end
|
|
450
|
+
|
|
439
451
|
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
|
|
440
452
|
# the same way as a frame without an id.
|
|
441
453
|
next unless parsed.is_a?(Hash) && parsed.key?("id")
|
|
@@ -451,6 +463,22 @@ module MCP
|
|
|
451
463
|
)
|
|
452
464
|
end
|
|
453
465
|
|
|
466
|
+
# The ping receiver "MUST respond promptly with an empty response". Answered inline from
|
|
467
|
+
# the read loop, so a keepalive cannot stall unanswered while a response is awaited;
|
|
468
|
+
# between requests nothing reads stdout, and a queued ping is answered on the next read.
|
|
469
|
+
def answer_ping(parsed)
|
|
470
|
+
@write_mutex.synchronize do
|
|
471
|
+
write_message({ jsonrpc: JsonRpcHandler::Version::V2_0, id: parsed["id"], result: {} })
|
|
472
|
+
end
|
|
473
|
+
rescue RequestHandlerError
|
|
474
|
+
# Best effort: a pong cannot be delivered over a broken stdin, and the failure must not
|
|
475
|
+
# surface as an error of the unrelated request whose response the loop is reading.
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def json_rpc_id?(id)
|
|
479
|
+
id.is_a?(String) || id.is_a?(Numeric)
|
|
480
|
+
end
|
|
481
|
+
|
|
454
482
|
def ensure_running!
|
|
455
483
|
return if @wait_thread.alive?
|
|
456
484
|
|
|
@@ -129,6 +129,12 @@ module MCP
|
|
|
129
129
|
# on a `subscriptions/listen` stream; the periodic write frees the stream's slot when the peer
|
|
130
130
|
# has gone away. Defaults to `DEFAULT_LISTEN_KEEPALIVE_INTERVAL` (15); pass `nil` to disable
|
|
131
131
|
# when an upstream proxy already keeps the stream alive.
|
|
132
|
+
# @param serve_subscriptions_listen [Boolean] whether `subscriptions/listen` opens a stream.
|
|
133
|
+
# A host that buffers responses and cannot serve an open SSE stream (e.g. the Rails controller pattern,
|
|
134
|
+
# which builds a fresh transport per request and renders the body) passes `false`:
|
|
135
|
+
# the method then answers 404 with JSON-RPC `-32601` like any unimplemented method,
|
|
136
|
+
# and `Server#discover` stops advertising the `listChanged`/`subscribe` capability flags,
|
|
137
|
+
# keeping the advertisement and the actual behavior in agreement. Defaults to `true`.
|
|
132
138
|
# @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
|
|
133
139
|
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
|
|
134
140
|
# Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
|
|
@@ -145,6 +151,7 @@ module MCP
|
|
|
145
151
|
max_request_bytes: DEFAULT_MAX_REQUEST_BYTES,
|
|
146
152
|
max_listen_subscriptions: DEFAULT_MAX_LISTEN_SUBSCRIPTIONS,
|
|
147
153
|
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
|
154
|
+
serve_subscriptions_listen: true,
|
|
148
155
|
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT
|
|
149
156
|
)
|
|
150
157
|
super(server)
|
|
@@ -162,7 +169,8 @@ module MCP
|
|
|
162
169
|
@allowed_origins = Array(allowed_origins).map(&:downcase).freeze
|
|
163
170
|
@pending_responses = {}
|
|
164
171
|
|
|
165
|
-
# Maps a `subscriptions/listen` request id to
|
|
172
|
+
# Maps a `subscriptions/listen` request id to
|
|
173
|
+
# `{ stream: stream_object, filter: honored_subscription_filter, active: boolean, write_mutex: Mutex }` (SEP-2575).
|
|
166
174
|
# In-process only; a multi-worker deployment needs an external event bus to fan notifications out across processes,
|
|
167
175
|
# which is a follow-up.
|
|
168
176
|
@listen_subscriptions = {}
|
|
@@ -211,6 +219,7 @@ module MCP
|
|
|
211
219
|
end
|
|
212
220
|
|
|
213
221
|
@listen_keepalive_interval = listen_keepalive_interval
|
|
222
|
+
@serve_subscriptions_listen = serve_subscriptions_listen
|
|
214
223
|
|
|
215
224
|
unless server_to_client_request_timeout.is_a?(Numeric) && server_to_client_request_timeout.positive?
|
|
216
225
|
raise ArgumentError, "server_to_client_request_timeout must be a positive number"
|
|
@@ -260,10 +269,12 @@ module MCP
|
|
|
260
269
|
handle_request(Rack::Request.new(env))
|
|
261
270
|
end
|
|
262
271
|
|
|
263
|
-
#
|
|
264
|
-
#
|
|
272
|
+
# Whether this transport serves the `subscriptions/listen` notification stream (SEP-2575).
|
|
273
|
+
# Gates both the route (a refusing transport answers the method as unimplemented) and
|
|
274
|
+
# the `listChanged`/`subscribe` capability flags `Server#discover` advertises,
|
|
275
|
+
# so the two always agree. Set via the `serve_subscriptions_listen:` constructor keyword.
|
|
265
276
|
def serves_subscriptions_listen?
|
|
266
|
-
|
|
277
|
+
@serve_subscriptions_listen
|
|
267
278
|
end
|
|
268
279
|
|
|
269
280
|
def handle_request(request)
|
|
@@ -723,8 +734,13 @@ module MCP
|
|
|
723
734
|
return mismatch_error if mismatch_error
|
|
724
735
|
|
|
725
736
|
# `subscriptions/listen` is a long-lived notification stream served at the transport layer;
|
|
726
|
-
# it never dispatches through `Server#handle`.
|
|
727
|
-
|
|
737
|
+
# it never dispatches through `Server#handle`. A transport constructed with
|
|
738
|
+
# `serve_subscriptions_listen: false` skips the interception, so the method falls through
|
|
739
|
+
# to the dispatcher as unimplemented (404 with `-32601`) - the refusal a host that cannot
|
|
740
|
+
# serve an open SSE stream needs, instead of a `Proc` body it can never call.
|
|
741
|
+
if body[:method] == Methods::SUBSCRIPTIONS_LISTEN && serves_subscriptions_listen?
|
|
742
|
+
return handle_subscriptions_listen(body)
|
|
743
|
+
end
|
|
728
744
|
|
|
729
745
|
session = modern_session
|
|
730
746
|
notifications = @mutex.synchronize { @modern_request_sinks[session.session_id] = [] }
|
|
@@ -881,17 +897,47 @@ module MCP
|
|
|
881
897
|
)
|
|
882
898
|
end
|
|
883
899
|
|
|
884
|
-
# The
|
|
900
|
+
# The Rack streaming body of a `subscriptions/listen` response. It responds to `call`
|
|
901
|
+
# and deliberately not to `each`, so Rack keeps classifying it as a streaming body;
|
|
902
|
+
# `first` exists only to turn the buffered-host mistake (e.g. `render(json: body.first)`
|
|
903
|
+
# in the Rails controller pattern) from a bare `NoMethodError` into guidance naming the fix.
|
|
904
|
+
class ListenStreamBody
|
|
905
|
+
def initialize(&block)
|
|
906
|
+
@block = block
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def call(stream)
|
|
910
|
+
@block.call(stream)
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
def first
|
|
914
|
+
raise <<~MESSAGE
|
|
915
|
+
subscriptions/listen returned a streaming SSE body, which cannot be buffered into a JSON response. \
|
|
916
|
+
A host that cannot hold an SSE response open should construct the transport with `serve_subscriptions_listen: false`, \
|
|
917
|
+
so the method is answered as unimplemented instead.
|
|
918
|
+
See the Rails (controller) section at https://ruby.sdk.modelcontextprotocol.io/server/transports/ for the hosting patterns.
|
|
919
|
+
MESSAGE
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
private_constant :ListenStreamBody
|
|
923
|
+
|
|
924
|
+
# The body registers the stream and returns, leaving the response open like
|
|
885
925
|
# the legacy GET stream (`create_sse_body`).
|
|
926
|
+
#
|
|
927
|
+
# Registration and activation are split on purpose: the entry is inserted inactive
|
|
928
|
+
# (reserving the id and the cap slot atomically), the acknowledgement is written outside the lock,
|
|
929
|
+
# and only then does the entry become eligible for delivery. A concurrent notification between
|
|
930
|
+
# the insert and the acknowledgement write skips the inactive entry,
|
|
931
|
+
# enforcing the SEP-2575 rule that no notification precedes the acknowledgement.
|
|
886
932
|
def listen_sse_body(request_id, honored)
|
|
887
|
-
|
|
933
|
+
ListenStreamBody.new do |stream|
|
|
888
934
|
rejected = false
|
|
889
935
|
@mutex.synchronize do
|
|
890
936
|
if @listen_subscriptions.key?(request_id) ||
|
|
891
937
|
(@max_listen_subscriptions && @listen_subscriptions.size >= @max_listen_subscriptions)
|
|
892
938
|
rejected = true
|
|
893
939
|
else
|
|
894
|
-
@listen_subscriptions[request_id] = { stream: stream, filter: honored }
|
|
940
|
+
@listen_subscriptions[request_id] = { stream: stream, filter: honored, active: false, write_mutex: Mutex.new }
|
|
895
941
|
end
|
|
896
942
|
end
|
|
897
943
|
|
|
@@ -909,6 +955,7 @@ module MCP
|
|
|
909
955
|
|
|
910
956
|
begin
|
|
911
957
|
send_to_stream(stream, acknowledgement)
|
|
958
|
+
activate_listen_subscription(request_id)
|
|
912
959
|
start_listen_keepalive_thread(request_id)
|
|
913
960
|
rescue *STREAM_WRITE_ERRORS
|
|
914
961
|
remove_listen_subscription(request_id)
|
|
@@ -918,6 +965,15 @@ module MCP
|
|
|
918
965
|
end
|
|
919
966
|
end
|
|
920
967
|
|
|
968
|
+
# Marks a listen subscription eligible for delivery once its acknowledgement write has completed.
|
|
969
|
+
# The entry may already be gone when the transport closed concurrently.
|
|
970
|
+
def activate_listen_subscription(request_id)
|
|
971
|
+
@mutex.synchronize do
|
|
972
|
+
subscription = @listen_subscriptions[request_id]
|
|
973
|
+
subscription[:active] = true if subscription
|
|
974
|
+
end
|
|
975
|
+
end
|
|
976
|
+
|
|
921
977
|
# Periodically writes an SSE keepalive comment frame to a listen stream so a silently dropped
|
|
922
978
|
# connection is detected and its slot freed, rather than held until the next fan-out write.
|
|
923
979
|
# Mirrors the legacy GET stream's `start_keepalive_thread`; a comment frame (not a data frame)
|
|
@@ -999,6 +1055,10 @@ module MCP
|
|
|
999
1055
|
# a slow or stalled subscriber must not block the transport, matching the legacy delivery paths.
|
|
1000
1056
|
matched = @mutex.synchronize do
|
|
1001
1057
|
@listen_subscriptions.filter_map do |request_id, subscription|
|
|
1058
|
+
# An inactive entry has not finished writing its acknowledgement yet;
|
|
1059
|
+
# delivering to it would put a notification ahead of the acknowledgement.
|
|
1060
|
+
next unless subscription[:active]
|
|
1061
|
+
|
|
1002
1062
|
hit = if field
|
|
1003
1063
|
subscription[:filter][field]
|
|
1004
1064
|
else
|
|
@@ -1007,24 +1067,32 @@ module MCP
|
|
|
1007
1067
|
uris.is_a?(Array) && uris.include?(uri)
|
|
1008
1068
|
end
|
|
1009
1069
|
|
|
1010
|
-
[request_id, subscription
|
|
1070
|
+
[request_id, subscription] if hit
|
|
1011
1071
|
end
|
|
1012
1072
|
end
|
|
1013
1073
|
|
|
1014
|
-
matched.each do |request_id,
|
|
1074
|
+
matched.each do |request_id, subscription|
|
|
1015
1075
|
meta = { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id }
|
|
1016
1076
|
notification_params = (params || {}).merge(_meta: meta)
|
|
1017
1077
|
notification = { jsonrpc: "2.0", method: method, params: notification_params }
|
|
1018
1078
|
|
|
1019
1079
|
begin
|
|
1020
|
-
|
|
1080
|
+
# The per-stream write mutex orders this write against a concurrent graceful teardown:
|
|
1081
|
+
# once teardown has marked the entry closed and written its `SubscriptionsListenResult`,
|
|
1082
|
+
# a delivery that snapshotted the entry before the registry was cleared skips it instead
|
|
1083
|
+
# of writing after the final message.
|
|
1084
|
+
subscription[:write_mutex].synchronize do
|
|
1085
|
+
next if subscription[:closed]
|
|
1086
|
+
|
|
1087
|
+
send_to_stream(subscription[:stream], notification)
|
|
1088
|
+
end
|
|
1021
1089
|
rescue *STREAM_WRITE_ERRORS => e
|
|
1022
1090
|
MCP.configuration.exception_reporter.call(
|
|
1023
1091
|
e,
|
|
1024
1092
|
{ subscription_id: request_id, error: "Failed to send notification" },
|
|
1025
1093
|
)
|
|
1026
1094
|
remove_listen_subscription(request_id)
|
|
1027
|
-
close_stream_safely(stream)
|
|
1095
|
+
close_stream_safely(subscription[:stream])
|
|
1028
1096
|
end
|
|
1029
1097
|
end
|
|
1030
1098
|
end
|
|
@@ -1043,20 +1111,27 @@ module MCP
|
|
|
1043
1111
|
end
|
|
1044
1112
|
|
|
1045
1113
|
removed.each do |request_id, subscription|
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1114
|
+
# Marking the entry closed and writing the result under the stream's write mutex orders
|
|
1115
|
+
# this against in-flight deliveries: each one either lands before the result or observes
|
|
1116
|
+
# `closed` and skips, keeping the graceful result the stream's final message.
|
|
1117
|
+
subscription[:write_mutex].synchronize do
|
|
1118
|
+
subscription[:closed] = true
|
|
1119
|
+
|
|
1120
|
+
begin
|
|
1121
|
+
send_to_stream(subscription[:stream], {
|
|
1122
|
+
jsonrpc: "2.0",
|
|
1123
|
+
id: request_id,
|
|
1124
|
+
result: {
|
|
1125
|
+
# `SubscriptionsListenResult` is served at the transport layer and never
|
|
1126
|
+
# passes through the dispatch path, so the REQUIRED 2026-07-28 `resultType` is
|
|
1127
|
+
# stamped at its construction site.
|
|
1128
|
+
resultType: ResultType::COMPLETE,
|
|
1129
|
+
_meta: { RequestEnvelope::SUBSCRIPTION_ID_META_KEY.to_sym => request_id },
|
|
1130
|
+
},
|
|
1131
|
+
})
|
|
1132
|
+
rescue *STREAM_WRITE_ERRORS
|
|
1133
|
+
nil
|
|
1134
|
+
end
|
|
1060
1135
|
end
|
|
1061
1136
|
close_stream_safely(subscription[:stream])
|
|
1062
1137
|
end
|
data/lib/mcp/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: mcp
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -46,6 +46,7 @@ files:
|
|
|
46
46
|
- lib/mcp/client/mcp_param_headers.rb
|
|
47
47
|
- lib/mcp/client/modern_envelope.rb
|
|
48
48
|
- lib/mcp/client/oauth.rb
|
|
49
|
+
- lib/mcp/client/oauth/authorization_request.rb
|
|
49
50
|
- lib/mcp/client/oauth/bounded_body.rb
|
|
50
51
|
- lib/mcp/client/oauth/client_credentials_provider.rb
|
|
51
52
|
- lib/mcp/client/oauth/cross_app_access_provider.rb
|
|
@@ -107,7 +108,7 @@ licenses:
|
|
|
107
108
|
- Apache-2.0
|
|
108
109
|
metadata:
|
|
109
110
|
allowed_push_host: https://rubygems.org
|
|
110
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.
|
|
111
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v1.5.0
|
|
111
112
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
112
113
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
113
114
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|