mcp 0.17.0 → 0.19.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 +34 -1
- data/lib/mcp/client/oauth/discovery.rb +44 -0
- data/lib/mcp/client/oauth/flow.rb +83 -2
- data/lib/mcp/client/oauth/provider.rb +30 -2
- data/lib/mcp/client/stdio.rb +4 -1
- data/lib/mcp/client.rb +25 -60
- data/lib/mcp/resource.rb +4 -2
- data/lib/mcp/server/transports/streamable_http_transport.rb +2 -2
- data/lib/mcp/server.rb +5 -0
- data/lib/mcp/server_context.rb +22 -0
- data/lib/mcp/server_session.rb +8 -0
- data/lib/mcp/tool/schema.rb +42 -1
- data/lib/mcp/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 959e38a26541c9681c11e8bc227e84c42d20e3d98de1568c0afd45c80f3b4474
|
|
4
|
+
data.tar.gz: e2bf6b0c7926e1803f9dff1279c1f5a35d96f1cf8a1e3c6141b22a97871c8ec2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5f347a7259f4e728df5edad217d6963cb3428188e46244efd9be9908e84bf4a7134600c6f453249eedc87e33f998f3f51811922659f116811251a64d78f1bbee
|
|
7
|
+
data.tar.gz: 3d8d25f65c590d07157db74d87423bbde82250b5d99cf2e3182c8adbc198f534f96809265c8615160f49f963a3b2dc2a07a85db2f6f2cc83e11b068583f405de
|
data/README.md
CHANGED
|
@@ -1253,6 +1253,25 @@ A `ping` request has no parameters, and the receiver MUST respond promptly with
|
|
|
1253
1253
|
Servers respond to incoming `ping` requests automatically - no setup is required.
|
|
1254
1254
|
Any `MCP::Server` instance replies with an empty result.
|
|
1255
1255
|
|
|
1256
|
+
Servers can also send `ping` requests to the client via `ServerSession#ping`.
|
|
1257
|
+
Inside a tool handler that receives `server_context:`, call `ping` on it:
|
|
1258
|
+
|
|
1259
|
+
```ruby
|
|
1260
|
+
class HealthCheckTool < MCP::Tool
|
|
1261
|
+
description "Verifies the client is still responsive"
|
|
1262
|
+
|
|
1263
|
+
def self.call(server_context:)
|
|
1264
|
+
server_context.ping # => {} on success
|
|
1265
|
+
|
|
1266
|
+
MCP::Tool::Response.new([{ type: "text", text: "client is alive" }])
|
|
1267
|
+
end
|
|
1268
|
+
end
|
|
1269
|
+
```
|
|
1270
|
+
|
|
1271
|
+
`#ping` raises `MCP::Server::ValidationError` when the client returns a `result`
|
|
1272
|
+
that is not a Hash. Transport-level errors (e.g., the client returning a JSON-RPC error)
|
|
1273
|
+
propagate as exceptions raised by the transport layer.
|
|
1274
|
+
|
|
1256
1275
|
#### Client-Side
|
|
1257
1276
|
|
|
1258
1277
|
`MCP::Client` exposes `ping` to send a ping to the server:
|
|
@@ -1736,7 +1755,7 @@ This class supports:
|
|
|
1736
1755
|
|
|
1737
1756
|
- Liveness check via the `ping` method (`MCP::Client#ping`)
|
|
1738
1757
|
- Tool listing via the `tools/list` method (`MCP::Client#tools`)
|
|
1739
|
-
- Tool invocation via the `tools/call` method (`MCP::Client#
|
|
1758
|
+
- Tool invocation via the `tools/call` method (`MCP::Client#call_tool`)
|
|
1740
1759
|
- Resource listing via the `resources/list` method (`MCP::Client#resources`)
|
|
1741
1760
|
- Resource template listing via the `resources/templates/list` method (`MCP::Client#resource_templates`)
|
|
1742
1761
|
- Resource reading via the `resources/read` method (`MCP::Client#read_resource`)
|
|
@@ -1894,6 +1913,9 @@ pass an `MCP::Client::OAuth::Provider` to the transport instead of a static `Aut
|
|
|
1894
1913
|
- On a `401 Unauthorized`, parse the `WWW-Authenticate` header, discover the authorization server (Protected Resource Metadata + RFC 8414 Authorization Server Metadata),
|
|
1895
1914
|
perform Dynamic Client Registration if needed, run the OAuth 2.1 Authorization Code flow with PKCE (S256), and retry the failed request with the acquired token.
|
|
1896
1915
|
- On subsequent 401s with a saved `refresh_token`, exchange it at the token endpoint before falling back to the full interactive flow (RFC 6749 Section 6).
|
|
1916
|
+
- Request the `offline_access` scope when `client_metadata[:grant_types]` includes `refresh_token` and the authorization server advertises `offline_access` in its metadata
|
|
1917
|
+
`scopes_supported` (SEP-2207). This is what lets the server issue the `refresh_token` used above. As an SDK-level safeguard, when the authorization server does not advertise
|
|
1918
|
+
`offline_access` the scope is also stripped from any other source (challenge, PRM, or provider-supplied scope) so a server that does not support it never receives it.
|
|
1897
1919
|
|
|
1898
1920
|
```ruby
|
|
1899
1921
|
require "mcp"
|
|
@@ -1939,6 +1961,17 @@ Optional keyword arguments:
|
|
|
1939
1961
|
- `scope`: Space-separated scopes to request when the server's `WWW-Authenticate` does not specify one.
|
|
1940
1962
|
- `storage`: Object responding to `tokens`, `save_tokens(t)`, `client_information`, `save_client_information(info)`. Defaults to `MCP::Client::OAuth::InMemoryStorage`,
|
|
1941
1963
|
which keeps credentials in process memory only.
|
|
1964
|
+
- `client_id_metadata_document_url`: URL where you publish a Client ID Metadata Document
|
|
1965
|
+
(`draft-ietf-oauth-client-id-metadata-document` and the MCP authorization specification).
|
|
1966
|
+
When the authorization server advertises `client_id_metadata_document_supported: true`,
|
|
1967
|
+
the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
|
|
1968
|
+
Spec-required: the URL MUST be `https://` with a non-root path and MUST NOT include a fragment,
|
|
1969
|
+
userinfo, or `.`/`..` segments. The SDK additionally rejects query strings (the draft only marks
|
|
1970
|
+
them SHOULD NOT include, but the SDK refuses to send any) for `client_id` stability.
|
|
1971
|
+
Any of these failures raise `Provider::InvalidClientIDMetadataDocumentURLError`. The CIMD document
|
|
1972
|
+
served at the URL is a separate JSON artifact from the `client_metadata` keyword above:
|
|
1973
|
+
the DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include
|
|
1974
|
+
`client_id` set to the document URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
1942
1975
|
|
|
1943
1976
|
To persist credentials across restarts, supply your own storage:
|
|
1944
1977
|
|
|
@@ -183,6 +183,50 @@ module MCP
|
|
|
183
183
|
false
|
|
184
184
|
end
|
|
185
185
|
|
|
186
|
+
# Returns true when `url` satisfies the structural requirements for
|
|
187
|
+
# a Client ID Metadata Document URL per the MCP 2025-11-25
|
|
188
|
+
# authorization specification and `draft-ietf-oauth-client-id-metadata-document-00`.
|
|
189
|
+
#
|
|
190
|
+
# Spec-required:
|
|
191
|
+
#
|
|
192
|
+
# - scheme MUST be `https` (the loopback-`http` carve-out used for discovery does not apply:
|
|
193
|
+
# the document URL is sent verbatim to the authorization server as the OAuth `client_id`
|
|
194
|
+
# and travels off-loopback)
|
|
195
|
+
# - host MUST be present
|
|
196
|
+
# - path MUST be non-empty and MUST NOT be the root (`/`); the document is a discrete resource,
|
|
197
|
+
# not the origin
|
|
198
|
+
# - URL MUST NOT carry a fragment or userinfo: a fragment is not sent to the server, and userinfo
|
|
199
|
+
# would leak credentials into every `client_id` log line
|
|
200
|
+
# - path MUST be already free of `.` / `..` dot segments after percent-decoding, so two URLs with
|
|
201
|
+
# the same effective path do not produce different `client_id` strings
|
|
202
|
+
#
|
|
203
|
+
# SDK policy (stricter than the draft):
|
|
204
|
+
#
|
|
205
|
+
# - URL MUST NOT carry a query string. The draft marks query components only SHOULD NOT include,
|
|
206
|
+
# but different encodings of the same query (`?a=1&b=2` vs `?b=2&a=1`) would yield distinct
|
|
207
|
+
# `client_id` values for the same logical document.
|
|
208
|
+
def client_id_metadata_document_url?(url)
|
|
209
|
+
return false if url.nil? || url.to_s.empty?
|
|
210
|
+
|
|
211
|
+
uri = URI.parse(url.to_s)
|
|
212
|
+
return false unless uri.scheme&.downcase == "https"
|
|
213
|
+
return false if uri.host.nil? || uri.host.empty?
|
|
214
|
+
return false unless uri.fragment.nil?
|
|
215
|
+
return false unless uri.query.nil?
|
|
216
|
+
return false if uri.respond_to?(:user) && (uri.user || uri.password)
|
|
217
|
+
|
|
218
|
+
path = uri.path.to_s
|
|
219
|
+
return false if path.empty? || path == "/"
|
|
220
|
+
|
|
221
|
+
decoded = path.gsub(/%2[eE]/, ".")
|
|
222
|
+
segments = decoded.split("/", -1)
|
|
223
|
+
return false if segments.any? { |segment| segment == "." || segment == ".." }
|
|
224
|
+
|
|
225
|
+
true
|
|
226
|
+
rescue URI::InvalidURIError
|
|
227
|
+
false
|
|
228
|
+
end
|
|
229
|
+
|
|
186
230
|
# Like `canonicalize_url` but also strips query string, fragment, and
|
|
187
231
|
# userinfo. This variant is used for identity comparison against
|
|
188
232
|
# the request URL Faraday actually sends, which differs from the value
|
|
@@ -59,6 +59,7 @@ module MCP
|
|
|
59
59
|
client_info = ensure_client_registered(as_metadata: as_metadata)
|
|
60
60
|
|
|
61
61
|
effective_scope = resolve_scope(scope: scope, prm: prm)
|
|
62
|
+
effective_scope = normalize_offline_access_scope(effective_scope, as_metadata: as_metadata)
|
|
62
63
|
pkce = PKCE.generate
|
|
63
64
|
state = SecureRandom.urlsafe_base64(32)
|
|
64
65
|
|
|
@@ -104,8 +105,16 @@ module MCP
|
|
|
104
105
|
refresh_token = read_token("refresh_token")
|
|
105
106
|
raise AuthorizationError, "Cannot refresh: no refresh_token in provider storage." unless refresh_token
|
|
106
107
|
|
|
107
|
-
|
|
108
|
-
|
|
108
|
+
stored_client_info = @provider.client_information
|
|
109
|
+
have_stored_client_info = stored_client_info.is_a?(Hash) && client_info_required_value(stored_client_info, "client_id")
|
|
110
|
+
|
|
111
|
+
# A CIMD-configured provider stores no `client_information` on purpose
|
|
112
|
+
# (the CIMD URL is re-resolved against the live AS metadata on every flow).
|
|
113
|
+
# Allow refresh to proceed in that case so the `refresh_token` obtained via
|
|
114
|
+
# the CIMD flow remains usable.
|
|
115
|
+
have_cimd_url = !@provider.client_id_metadata_document_url.nil?
|
|
116
|
+
|
|
117
|
+
unless have_stored_client_info || have_cimd_url
|
|
109
118
|
raise AuthorizationError, "Cannot refresh: no client_information in provider storage."
|
|
110
119
|
end
|
|
111
120
|
|
|
@@ -126,6 +135,18 @@ module MCP
|
|
|
126
135
|
ensure_issuer_matches!(expected: authorization_server, returned: as_metadata["issuer"])
|
|
127
136
|
ensure_secure_endpoints!(as_metadata)
|
|
128
137
|
|
|
138
|
+
client_info = if have_stored_client_info
|
|
139
|
+
# Pre-registered / DCR-issued `client_information` always wins: if the user picked an explicit identity,
|
|
140
|
+
# do not silently swap it for the CIMD URL even when the AS also advertises CIMD support.
|
|
141
|
+
stored_client_info
|
|
142
|
+
elsif as_metadata["client_id_metadata_document_supported"] == true
|
|
143
|
+
{ "client_id" => @provider.client_id_metadata_document_url }
|
|
144
|
+
else
|
|
145
|
+
raise AuthorizationError,
|
|
146
|
+
"Cannot refresh: provider has a CIMD URL but the authorization server no longer advertises " \
|
|
147
|
+
"`client_id_metadata_document_supported: true`."
|
|
148
|
+
end
|
|
149
|
+
|
|
129
150
|
new_tokens = exchange_refresh_token(
|
|
130
151
|
as_metadata: as_metadata,
|
|
131
152
|
client_info: client_info,
|
|
@@ -285,6 +306,24 @@ module MCP
|
|
|
285
306
|
existing = @provider.client_information
|
|
286
307
|
return existing if existing.is_a?(Hash) && client_info_required_value(existing, "client_id")
|
|
287
308
|
|
|
309
|
+
# Per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`,
|
|
310
|
+
# if the authorization server advertises Client ID Metadata Document support and the provider has
|
|
311
|
+
# a CIMD URL configured, use the URL as the OAuth `client_id` and skip Dynamic Client Registration.
|
|
312
|
+
#
|
|
313
|
+
# The `== true` comparison is intentional: only a JSON `boolean` `true` opts the flow in.
|
|
314
|
+
# A string `"false"`, an empty Hash, or any other truthy value MUST NOT be treated as CIMD support,
|
|
315
|
+
# otherwise a misconfigured AS could trick the client into using the CIMD `client_id` against
|
|
316
|
+
# a server that has not actually adopted it.
|
|
317
|
+
#
|
|
318
|
+
# The CIMD `client_id` is NOT persisted to storage. The AS may later stop advertising CIMD support
|
|
319
|
+
# (or the operator may rotate the CIMD URL), and a stale `client_information` entry would otherwise
|
|
320
|
+
# keep sending the old CIMD URL forever. Re-evaluating on every flow re-reads the current AS metadata
|
|
321
|
+
# and the current `provider.client_id_metadata_document_url`.
|
|
322
|
+
cimd_url = @provider.client_id_metadata_document_url
|
|
323
|
+
if cimd_url && as_metadata["client_id_metadata_document_supported"] == true
|
|
324
|
+
return { "client_id" => cimd_url }
|
|
325
|
+
end
|
|
326
|
+
|
|
288
327
|
registration_endpoint = as_metadata["registration_endpoint"]
|
|
289
328
|
unless registration_endpoint
|
|
290
329
|
raise AuthorizationError,
|
|
@@ -403,6 +442,48 @@ module MCP
|
|
|
403
442
|
nil
|
|
404
443
|
end
|
|
405
444
|
|
|
445
|
+
# Applies the SDK's `offline_access` policy to the resolved scope. The policy has two halves:
|
|
446
|
+
#
|
|
447
|
+
# - Spec (SEP-2207): a client that wants a refresh token (signalled here by listing
|
|
448
|
+
# `refresh_token` in its registered `grant_types`) MAY request `offline_access`
|
|
449
|
+
# when the authorization server advertises it in metadata `scopes_supported`.
|
|
450
|
+
# When the server advertises it and the client opted in, add it if absent.
|
|
451
|
+
#
|
|
452
|
+
# - SDK policy (defensive hardening): when the server does NOT advertise `offline_access`,
|
|
453
|
+
# strip it from the resolved scope no matter where it came from (the `WWW-Authenticate` challenge,
|
|
454
|
+
# PRM `scopes_supported`, or the provider-supplied scope). SEP-2207 only says clients SHOULD NOT
|
|
455
|
+
# request unsupported scopes, but a misbehaving RS that includes `offline_access` in its challenge,
|
|
456
|
+
# or a misconfigured PRM that lists it under `scopes_supported`, would otherwise propagate into
|
|
457
|
+
# the authorization request even though the AS will not honour it. Stripping here keeps the SDK's
|
|
458
|
+
# own request consistent with the AS's advertisement.
|
|
459
|
+
#
|
|
460
|
+
# Returns `nil` when the result is empty so `build_authorization_url` omits the `scope` parameter entirely.
|
|
461
|
+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2207
|
|
462
|
+
def normalize_offline_access_scope(scope, as_metadata:)
|
|
463
|
+
scopes = scope.to_s.split
|
|
464
|
+
|
|
465
|
+
if server_supports_offline_access?(as_metadata)
|
|
466
|
+
scopes << "offline_access" if wants_refresh_token? && !scopes.include?("offline_access")
|
|
467
|
+
else
|
|
468
|
+
scopes.delete("offline_access")
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
scopes.empty? ? nil : scopes.join(" ")
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def server_supports_offline_access?(as_metadata)
|
|
475
|
+
supported = as_metadata["scopes_supported"]
|
|
476
|
+
|
|
477
|
+
supported.is_a?(Array) && supported.include?("offline_access")
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def wants_refresh_token?
|
|
481
|
+
metadata = @provider.client_metadata
|
|
482
|
+
grant_types = metadata[:grant_types] || metadata["grant_types"]
|
|
483
|
+
|
|
484
|
+
Array(grant_types).include?("refresh_token")
|
|
485
|
+
end
|
|
486
|
+
|
|
406
487
|
def build_authorization_url(as_metadata:, client_id:, scope:, state:, code_challenge:, resource:)
|
|
407
488
|
authorization_endpoint = as_metadata["authorization_endpoint"]
|
|
408
489
|
unless authorization_endpoint
|
|
@@ -25,6 +25,16 @@ module MCP
|
|
|
25
25
|
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
26
26
|
# `client_information`, and `save_client_information(info)`. Defaults to
|
|
27
27
|
# an `InMemoryStorage`.
|
|
28
|
+
# - `client_id_metadata_document_url` - URL where the client publishes its Client ID Metadata Document
|
|
29
|
+
# (`draft-ietf-oauth-client-id-metadata-document-00` and the MCP authorization specification).
|
|
30
|
+
# When the authorization server advertises `client_id_metadata_document_supported: true`,
|
|
31
|
+
# the SDK uses this URL as the OAuth `client_id` and skips Dynamic Client Registration.
|
|
32
|
+
# Spec-required: `https://` scheme, a non-root path, and no fragment, userinfo, or `.`/`..` segments.
|
|
33
|
+
# The SDK additionally refuses to send query strings (the draft marks them only SHOULD NOT include,
|
|
34
|
+
# but different encodings of the same query would yield different `client_id` strings for the same document).
|
|
35
|
+
# The document served at the URL is a separate JSON artifact from the `client_metadata` keyword:
|
|
36
|
+
# DCR `client_metadata` MUST NOT include `client_id`, while the CIMD document MUST include `client_id` set
|
|
37
|
+
# to the URL, `client_name`, and `redirect_uris` covering `redirect_uri`.
|
|
28
38
|
class Provider
|
|
29
39
|
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
|
30
40
|
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
|
@@ -38,12 +48,21 @@ module MCP
|
|
|
38
48
|
# runtime; failing at construction surfaces the bug earlier.
|
|
39
49
|
class UnregisteredRedirectURIError < ArgumentError; end
|
|
40
50
|
|
|
51
|
+
# Raised when `client_id_metadata_document_url` is provided but does not meet
|
|
52
|
+
# the structural requirements for a Client ID Metadata Document URL:
|
|
53
|
+
# HTTPS, non-root path, and no fragment, query, userinfo, or `.`/`..` segments.
|
|
54
|
+
# The CIMD URL is sent to the authorization server as the OAuth `client_id`,
|
|
55
|
+
# so the same Communication Security guarantee that protects the redirect URI
|
|
56
|
+
# applies and the value must unambiguously identify the document.
|
|
57
|
+
class InvalidClientIDMetadataDocumentURLError < ArgumentError; end
|
|
58
|
+
|
|
41
59
|
attr_reader :client_metadata,
|
|
42
60
|
:redirect_uri,
|
|
43
61
|
:scope,
|
|
44
62
|
:storage,
|
|
45
63
|
:redirect_handler,
|
|
46
|
-
:callback_handler
|
|
64
|
+
:callback_handler,
|
|
65
|
+
:client_id_metadata_document_url
|
|
47
66
|
|
|
48
67
|
def initialize(
|
|
49
68
|
client_metadata:,
|
|
@@ -51,7 +70,8 @@ module MCP
|
|
|
51
70
|
redirect_handler:,
|
|
52
71
|
callback_handler:,
|
|
53
72
|
scope: nil,
|
|
54
|
-
storage: nil
|
|
73
|
+
storage: nil,
|
|
74
|
+
client_id_metadata_document_url: nil
|
|
55
75
|
)
|
|
56
76
|
unless Discovery.secure_url?(redirect_uri)
|
|
57
77
|
raise InsecureRedirectURIError,
|
|
@@ -66,12 +86,20 @@ module MCP
|
|
|
66
86
|
"(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
|
|
67
87
|
end
|
|
68
88
|
|
|
89
|
+
if client_id_metadata_document_url && !Discovery.client_id_metadata_document_url?(client_id_metadata_document_url)
|
|
90
|
+
raise InvalidClientIDMetadataDocumentURLError,
|
|
91
|
+
"client_id_metadata_document_url #{client_id_metadata_document_url.inspect} must be an https URL " \
|
|
92
|
+
"with a non-root path and no fragment, query, userinfo, or `.`/`..` segments, " \
|
|
93
|
+
"per the MCP authorization specification and `draft-ietf-oauth-client-id-metadata-document`."
|
|
94
|
+
end
|
|
95
|
+
|
|
69
96
|
@client_metadata = client_metadata
|
|
70
97
|
@redirect_uri = redirect_uri
|
|
71
98
|
@redirect_handler = redirect_handler
|
|
72
99
|
@callback_handler = callback_handler
|
|
73
100
|
@scope = scope
|
|
74
101
|
@storage = storage || InMemoryStorage.new
|
|
102
|
+
@client_id_metadata_document_url = client_id_metadata_document_url
|
|
75
103
|
end
|
|
76
104
|
|
|
77
105
|
def access_token
|
data/lib/mcp/client/stdio.rb
CHANGED
|
@@ -134,7 +134,10 @@ module MCP
|
|
|
134
134
|
|
|
135
135
|
def send_request(request:)
|
|
136
136
|
start unless @started
|
|
137
|
-
|
|
137
|
+
unless @initialized
|
|
138
|
+
warn("Calling `MCP::Client::Stdio#send_request` without calling `MCP::Client#connect` is deprecated. Use `MCP::Client#connect` before sending requests instead.", uplevel: 1)
|
|
139
|
+
connect
|
|
140
|
+
end
|
|
138
141
|
|
|
139
142
|
write_message(request)
|
|
140
143
|
read_response(request)
|
data/lib/mcp/client.rb
CHANGED
|
@@ -146,21 +146,7 @@ module MCP
|
|
|
146
146
|
# end
|
|
147
147
|
def tools
|
|
148
148
|
# TODO: consider renaming to `list_all_tools`.
|
|
149
|
-
|
|
150
|
-
seen = Set.new
|
|
151
|
-
cursor = nil
|
|
152
|
-
|
|
153
|
-
loop do
|
|
154
|
-
page = list_tools(cursor: cursor)
|
|
155
|
-
all_tools.concat(page.tools)
|
|
156
|
-
next_cursor = page.next_cursor
|
|
157
|
-
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
158
|
-
|
|
159
|
-
seen << next_cursor
|
|
160
|
-
cursor = next_cursor
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
all_tools
|
|
149
|
+
fetch_all_pages { |cursor| list_tools(cursor: cursor) }.flat_map(&:tools)
|
|
164
150
|
end
|
|
165
151
|
|
|
166
152
|
# Returns a single page of resources from the server.
|
|
@@ -189,21 +175,7 @@ module MCP
|
|
|
189
175
|
# @return [Array<Hash>] An array of available resources.
|
|
190
176
|
def resources
|
|
191
177
|
# TODO: consider renaming to `list_all_resources`.
|
|
192
|
-
|
|
193
|
-
seen = Set.new
|
|
194
|
-
cursor = nil
|
|
195
|
-
|
|
196
|
-
loop do
|
|
197
|
-
page = list_resources(cursor: cursor)
|
|
198
|
-
all_resources.concat(page.resources)
|
|
199
|
-
next_cursor = page.next_cursor
|
|
200
|
-
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
201
|
-
|
|
202
|
-
seen << next_cursor
|
|
203
|
-
cursor = next_cursor
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
all_resources
|
|
178
|
+
fetch_all_pages { |cursor| list_resources(cursor: cursor) }.flat_map(&:resources)
|
|
207
179
|
end
|
|
208
180
|
|
|
209
181
|
# Returns a single page of resource templates from the server.
|
|
@@ -232,21 +204,7 @@ module MCP
|
|
|
232
204
|
# @return [Array<Hash>] An array of available resource templates.
|
|
233
205
|
def resource_templates
|
|
234
206
|
# TODO: consider renaming to `list_all_resource_templates`.
|
|
235
|
-
|
|
236
|
-
seen = Set.new
|
|
237
|
-
cursor = nil
|
|
238
|
-
|
|
239
|
-
loop do
|
|
240
|
-
page = list_resource_templates(cursor: cursor)
|
|
241
|
-
all_templates.concat(page.resource_templates)
|
|
242
|
-
next_cursor = page.next_cursor
|
|
243
|
-
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
244
|
-
|
|
245
|
-
seen << next_cursor
|
|
246
|
-
cursor = next_cursor
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
all_templates
|
|
207
|
+
fetch_all_pages { |cursor| list_resource_templates(cursor: cursor) }.flat_map(&:resource_templates)
|
|
250
208
|
end
|
|
251
209
|
|
|
252
210
|
# Returns a single page of prompts from the server.
|
|
@@ -275,21 +233,7 @@ module MCP
|
|
|
275
233
|
# @return [Array<Hash>] An array of available prompts.
|
|
276
234
|
def prompts
|
|
277
235
|
# TODO: consider renaming to `list_all_prompts`.
|
|
278
|
-
|
|
279
|
-
seen = Set.new
|
|
280
|
-
cursor = nil
|
|
281
|
-
|
|
282
|
-
loop do
|
|
283
|
-
page = list_prompts(cursor: cursor)
|
|
284
|
-
all_prompts.concat(page.prompts)
|
|
285
|
-
next_cursor = page.next_cursor
|
|
286
|
-
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
287
|
-
|
|
288
|
-
seen << next_cursor
|
|
289
|
-
cursor = next_cursor
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
all_prompts
|
|
236
|
+
fetch_all_pages { |cursor| list_prompts(cursor: cursor) }.flat_map(&:prompts)
|
|
293
237
|
end
|
|
294
238
|
|
|
295
239
|
# Calls a tool via the transport layer and returns the full response from the server.
|
|
@@ -380,6 +324,27 @@ module MCP
|
|
|
380
324
|
|
|
381
325
|
private
|
|
382
326
|
|
|
327
|
+
# Walks every page of a list endpoint, following `next_cursor`, and returns
|
|
328
|
+
# the page results. The `seen` set guards against a server that repeats or
|
|
329
|
+
# cycles cursors, so the loop always terminates.
|
|
330
|
+
def fetch_all_pages
|
|
331
|
+
pages = []
|
|
332
|
+
seen = Set.new
|
|
333
|
+
cursor = nil
|
|
334
|
+
|
|
335
|
+
loop do
|
|
336
|
+
page = yield(cursor)
|
|
337
|
+
pages << page
|
|
338
|
+
next_cursor = page.next_cursor
|
|
339
|
+
break if next_cursor.nil? || seen.include?(next_cursor)
|
|
340
|
+
|
|
341
|
+
seen << next_cursor
|
|
342
|
+
cursor = next_cursor
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
pages
|
|
346
|
+
end
|
|
347
|
+
|
|
383
348
|
def request(method:, params: nil)
|
|
384
349
|
request_body = {
|
|
385
350
|
jsonrpc: JsonRpcHandler::Version::V2_0,
|
data/lib/mcp/resource.rb
CHANGED
|
@@ -5,15 +5,16 @@ require_relative "resource/embedded"
|
|
|
5
5
|
|
|
6
6
|
module MCP
|
|
7
7
|
class Resource
|
|
8
|
-
attr_reader :uri, :name, :title, :description, :icons, :mime_type, :meta
|
|
8
|
+
attr_reader :uri, :name, :title, :description, :icons, :mime_type, :size, :meta
|
|
9
9
|
|
|
10
|
-
def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, meta: nil)
|
|
10
|
+
def initialize(uri:, name:, title: nil, description: nil, icons: [], mime_type: nil, size: nil, meta: nil)
|
|
11
11
|
@uri = uri
|
|
12
12
|
@name = name
|
|
13
13
|
@title = title
|
|
14
14
|
@description = description
|
|
15
15
|
@icons = icons
|
|
16
16
|
@mime_type = mime_type
|
|
17
|
+
@size = size
|
|
17
18
|
@meta = meta
|
|
18
19
|
end
|
|
19
20
|
|
|
@@ -25,6 +26,7 @@ module MCP
|
|
|
25
26
|
description: description,
|
|
26
27
|
icons: icons&.then { |icons| icons.empty? ? nil : icons.map(&:to_h) },
|
|
27
28
|
mimeType: mime_type,
|
|
29
|
+
size: size,
|
|
28
30
|
_meta: meta,
|
|
29
31
|
}.compact
|
|
30
32
|
end
|
|
@@ -426,7 +426,7 @@ module MCP
|
|
|
426
426
|
return success_response
|
|
427
427
|
end
|
|
428
428
|
|
|
429
|
-
return missing_session_id_response unless (session_id = request
|
|
429
|
+
return missing_session_id_response unless (session_id = extract_session_id(request))
|
|
430
430
|
return session_not_found_response unless session_exists?(session_id)
|
|
431
431
|
|
|
432
432
|
protocol_version_error = validate_protocol_version_header(request)
|
|
@@ -504,7 +504,7 @@ module MCP
|
|
|
504
504
|
|
|
505
505
|
def parse_accept_header(header)
|
|
506
506
|
header.split(",").map do |part|
|
|
507
|
-
part.split(";").first.strip
|
|
507
|
+
part.split(";").first.strip.downcase
|
|
508
508
|
end
|
|
509
509
|
end
|
|
510
510
|
|
data/lib/mcp/server.rb
CHANGED
|
@@ -67,6 +67,11 @@ module MCP
|
|
|
67
67
|
end
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
+
# Raised when a client response fails server-side validation, e.g., a success response
|
|
71
|
+
# whose `result` field is missing or has the wrong type. This is distinct from a
|
|
72
|
+
# client-returned JSON-RPC error.
|
|
73
|
+
class ValidationError < StandardError; end
|
|
74
|
+
|
|
70
75
|
include Instrumentation
|
|
71
76
|
include Pagination
|
|
72
77
|
|
data/lib/mcp/server_context.rb
CHANGED
|
@@ -59,6 +59,28 @@ module MCP
|
|
|
59
59
|
end
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Sends a `ping` request to the originating client to verify it is still responsive.
|
|
63
|
+
# Per the MCP spec, the client MUST respond promptly with an empty result.
|
|
64
|
+
#
|
|
65
|
+
# @return [Hash] An empty hash on success.
|
|
66
|
+
# @raise [Server::ValidationError] If the response `result` is not a Hash.
|
|
67
|
+
# @raise [NoMethodError] If the session does not support sending pings.
|
|
68
|
+
#
|
|
69
|
+
# @example
|
|
70
|
+
# def self.call(server_context:)
|
|
71
|
+
# server_context.ping # => {}
|
|
72
|
+
# # ...
|
|
73
|
+
# end
|
|
74
|
+
#
|
|
75
|
+
# @see https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/ping
|
|
76
|
+
def ping
|
|
77
|
+
if @notification_target.respond_to?(:ping)
|
|
78
|
+
@notification_target.ping(related_request_id: @related_request_id)
|
|
79
|
+
else
|
|
80
|
+
raise NoMethodError, "undefined method 'ping' for #{self}"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
62
84
|
# Delegates to the session so the request is scoped to the originating client.
|
|
63
85
|
# Falls back to `@context` (via `method_missing`) when `@notification_target`
|
|
64
86
|
# does not support sampling.
|
data/lib/mcp/server_session.rb
CHANGED
|
@@ -106,6 +106,14 @@ module MCP
|
|
|
106
106
|
send_to_transport_request(Methods::ROOTS_LIST, nil, related_request_id: related_request_id)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
+
# Sends a `ping` request scoped to this session.
|
|
110
|
+
def ping(related_request_id: nil)
|
|
111
|
+
result = send_to_transport_request(Methods::PING, nil, related_request_id: related_request_id)
|
|
112
|
+
raise Server::ValidationError, "Response validation failed: invalid `result`" unless result.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
result
|
|
115
|
+
end
|
|
116
|
+
|
|
109
117
|
# Sends a `sampling/createMessage` request scoped to this session.
|
|
110
118
|
def create_sampling_message(related_request_id: nil, **kwargs)
|
|
111
119
|
params = @server.build_sampling_params(client_capabilities, **kwargs)
|
data/lib/mcp/tool/schema.rb
CHANGED
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
3
4
|
require "json-schema"
|
|
4
5
|
|
|
5
6
|
module MCP
|
|
6
7
|
class Tool
|
|
7
8
|
class Schema
|
|
9
|
+
# Metaschema validation depends only on schema content, so a given schema
|
|
10
|
+
# never needs to be validated more than once. Caching the result lets repeated
|
|
11
|
+
# (e.g. dynamically rebuilt) schemas skip the costly traversal.
|
|
12
|
+
class ValidationCache
|
|
13
|
+
DEFAULT_MAX_SIZE = 1000
|
|
14
|
+
|
|
15
|
+
def initialize(max_size: DEFAULT_MAX_SIZE)
|
|
16
|
+
@max_size = max_size
|
|
17
|
+
@entries = {}
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def validated?(key)
|
|
22
|
+
@mutex.synchronize { @entries.key?(key) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def store(key)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
@entries.delete(key)
|
|
28
|
+
@entries[key] = true
|
|
29
|
+
@entries.shift while @entries.size > @max_size
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear
|
|
34
|
+
@mutex.synchronize { @entries.clear }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
VALIDATION_CACHE = ValidationCache.new
|
|
38
|
+
|
|
8
39
|
# JSON Schema 2020-12 is the default dialect for MCP schema definitions
|
|
9
40
|
# per MCP 2025-11-25 (SEP-1613). Note: emission only — runtime validation
|
|
10
41
|
# is still performed against the JSON Schema draft-04 metaschema because
|
|
@@ -36,6 +67,14 @@ module MCP
|
|
|
36
67
|
end
|
|
37
68
|
|
|
38
69
|
def validate_schema!
|
|
70
|
+
target = schema_for_validation
|
|
71
|
+
|
|
72
|
+
# `max_nesting: false` because normalization uses `JSON.dump` (no nesting limit),
|
|
73
|
+
# so the default `JSON.generate` limit would raise on a deeply nested schema that
|
|
74
|
+
# the initializer already accepted.
|
|
75
|
+
key = Digest::SHA256.hexdigest(JSON.generate(target, max_nesting: false))
|
|
76
|
+
return if VALIDATION_CACHE.validated?(key)
|
|
77
|
+
|
|
39
78
|
gem_path = File.realpath(Gem.loaded_specs["json-schema"].full_gem_path)
|
|
40
79
|
schema_reader = JSON::Schema::Reader.new(
|
|
41
80
|
accept_uri: false,
|
|
@@ -45,10 +84,12 @@ module MCP
|
|
|
45
84
|
# Converts metaschema to a file URI for cross-platform compatibility
|
|
46
85
|
metaschema_uri = JSON::Util::URI.file_uri(metaschema_path.expand_path.cleanpath.to_s.tr("\\", "/"))
|
|
47
86
|
metaschema = metaschema_uri.to_s
|
|
48
|
-
errors = JSON::Validator.fully_validate(metaschema,
|
|
87
|
+
errors = JSON::Validator.fully_validate(metaschema, target, schema_reader: schema_reader)
|
|
49
88
|
if errors.any?
|
|
50
89
|
raise ArgumentError, "Invalid JSON Schema: #{errors.join(", ")}"
|
|
51
90
|
end
|
|
91
|
+
|
|
92
|
+
VALIDATION_CACHE.store(key)
|
|
52
93
|
end
|
|
53
94
|
|
|
54
95
|
# The `json-schema` gem's draft-04 validator cannot resolve newer or unknown `$schema`
|
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: 0.
|
|
4
|
+
version: 0.19.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -87,7 +87,7 @@ licenses:
|
|
|
87
87
|
- Apache-2.0
|
|
88
88
|
metadata:
|
|
89
89
|
allowed_push_host: https://rubygems.org
|
|
90
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.
|
|
90
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.19.0
|
|
91
91
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
92
92
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
93
93
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|