mcp 0.16.0 → 0.17.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 +107 -0
- data/lib/mcp/client/http.rb +202 -55
- data/lib/mcp/client/oauth/discovery.rb +423 -0
- data/lib/mcp/client/oauth/flow.rb +587 -0
- data/lib/mcp/client/oauth/in_memory_storage.rb +43 -0
- data/lib/mcp/client/oauth/pkce.rb +38 -0
- data/lib/mcp/client/oauth/provider.rb +103 -0
- data/lib/mcp/client/oauth.rb +18 -0
- data/lib/mcp/client.rb +1 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +127 -13
- data/lib/mcp/server.rb +9 -0
- data/lib/mcp/server_session.rb +13 -0
- data/lib/mcp/version.rb +1 -1
- metadata +8 -2
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MCP
|
|
4
|
+
class Client
|
|
5
|
+
module OAuth
|
|
6
|
+
# Pluggable OAuth client configuration handed to `MCP::Client::HTTP` via
|
|
7
|
+
# the `oauth:` keyword. Inspired by the OAuthClientProvider in the TypeScript SDK
|
|
8
|
+
# and httpx.Auth-based provider in the Python SDK.
|
|
9
|
+
#
|
|
10
|
+
# Required keyword arguments:
|
|
11
|
+
# - `client_metadata` - Hash sent to the authorization server's Dynamic Client
|
|
12
|
+
# Registration endpoint. Must include at minimum `redirect_uris`,
|
|
13
|
+
# `grant_types`, `response_types`, and `token_endpoint_auth_method`.
|
|
14
|
+
# - `redirect_uri` - String: the redirect URI used for the authorization
|
|
15
|
+
# request. Must be one of `redirect_uris` in `client_metadata`.
|
|
16
|
+
# - `redirect_handler` - Callable invoked with the fully-built authorization
|
|
17
|
+
# URL (a `URI`). Implementations typically open the user's browser.
|
|
18
|
+
# - `callback_handler` - Callable invoked after `redirect_handler`. Returns
|
|
19
|
+
# `[code, state]` where `code` is the authorization code and `state` is
|
|
20
|
+
# the `state` parameter received on the redirect URI.
|
|
21
|
+
#
|
|
22
|
+
# Optional keyword arguments:
|
|
23
|
+
# - `scope` - String of space-separated scopes to request when the server's
|
|
24
|
+
# `WWW-Authenticate` does not specify one.
|
|
25
|
+
# - `storage` - Object responding to `tokens`, `save_tokens(tokens)`,
|
|
26
|
+
# `client_information`, and `save_client_information(info)`. Defaults to
|
|
27
|
+
# an `InMemoryStorage`.
|
|
28
|
+
class Provider
|
|
29
|
+
# Raised when `Provider#initialize` is called with a `redirect_uri` that
|
|
30
|
+
# is neither HTTPS nor a loopback `http://` URL, per the MCP
|
|
31
|
+
# authorization spec's Communication Security requirement.
|
|
32
|
+
class InsecureRedirectURIError < ArgumentError; end
|
|
33
|
+
|
|
34
|
+
# Raised when the `redirect_uri` argument is not listed in
|
|
35
|
+
# `client_metadata[:redirect_uris]` / `["redirect_uris"]`. Registering
|
|
36
|
+
# the URI with the authorization server but then sending a different
|
|
37
|
+
# one with the authorization request would be rejected by the AS at
|
|
38
|
+
# runtime; failing at construction surfaces the bug earlier.
|
|
39
|
+
class UnregisteredRedirectURIError < ArgumentError; end
|
|
40
|
+
|
|
41
|
+
attr_reader :client_metadata,
|
|
42
|
+
:redirect_uri,
|
|
43
|
+
:scope,
|
|
44
|
+
:storage,
|
|
45
|
+
:redirect_handler,
|
|
46
|
+
:callback_handler
|
|
47
|
+
|
|
48
|
+
def initialize(
|
|
49
|
+
client_metadata:,
|
|
50
|
+
redirect_uri:,
|
|
51
|
+
redirect_handler:,
|
|
52
|
+
callback_handler:,
|
|
53
|
+
scope: nil,
|
|
54
|
+
storage: nil
|
|
55
|
+
)
|
|
56
|
+
unless Discovery.secure_url?(redirect_uri)
|
|
57
|
+
raise InsecureRedirectURIError,
|
|
58
|
+
"redirect_uri #{redirect_uri.inspect} must use https or be a loopback http URL " \
|
|
59
|
+
"(localhost, 127.0.0.0/8, or ::1) per the MCP authorization Communication Security requirement."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
registered = Array(client_metadata[:redirect_uris] || client_metadata["redirect_uris"])
|
|
63
|
+
unless registered.include?(redirect_uri)
|
|
64
|
+
raise UnregisteredRedirectURIError,
|
|
65
|
+
"redirect_uri #{redirect_uri.inspect} must be listed in client_metadata[:redirect_uris] " \
|
|
66
|
+
"(got #{registered.inspect}); otherwise the authorization server will reject the authorization request."
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@client_metadata = client_metadata
|
|
70
|
+
@redirect_uri = redirect_uri
|
|
71
|
+
@redirect_handler = redirect_handler
|
|
72
|
+
@callback_handler = callback_handler
|
|
73
|
+
@scope = scope
|
|
74
|
+
@storage = storage || InMemoryStorage.new
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def access_token
|
|
78
|
+
tokens&.dig("access_token") || tokens&.dig(:access_token)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def tokens
|
|
82
|
+
@storage.tokens
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def save_tokens(tokens)
|
|
86
|
+
@storage.save_tokens(tokens)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def client_information
|
|
90
|
+
@storage.client_information
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def save_client_information(info)
|
|
94
|
+
@storage.save_client_information(info)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def clear_tokens!
|
|
98
|
+
@storage.save_tokens(nil)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "oauth/discovery"
|
|
4
|
+
require_relative "oauth/flow"
|
|
5
|
+
require_relative "oauth/in_memory_storage"
|
|
6
|
+
require_relative "oauth/pkce"
|
|
7
|
+
require_relative "oauth/provider"
|
|
8
|
+
|
|
9
|
+
module MCP
|
|
10
|
+
class Client
|
|
11
|
+
# OAuth client support for the MCP Authorization spec (PRM discovery,
|
|
12
|
+
# Authorization Server metadata discovery, Dynamic Client Registration,
|
|
13
|
+
# OAuth 2.1 Authorization Code + PKCE).
|
|
14
|
+
# https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization
|
|
15
|
+
module OAuth
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/mcp/client.rb
CHANGED
|
@@ -16,6 +16,8 @@ module MCP
|
|
|
16
16
|
class Server
|
|
17
17
|
module Transports
|
|
18
18
|
class StreamableHTTPTransport < Transport
|
|
19
|
+
class InvalidJsonError < StandardError; end
|
|
20
|
+
|
|
19
21
|
SSE_HEADERS = {
|
|
20
22
|
"Content-Type" => "text/event-stream",
|
|
21
23
|
"Cache-Control" => "no-cache",
|
|
@@ -339,24 +341,51 @@ module MCP
|
|
|
339
341
|
body_string = request.body.read
|
|
340
342
|
session_id = extract_session_id(request)
|
|
341
343
|
|
|
342
|
-
|
|
343
|
-
|
|
344
|
+
begin
|
|
345
|
+
body = parse_request_body(body_string)
|
|
346
|
+
rescue InvalidJsonError
|
|
347
|
+
return invalid_json_response
|
|
348
|
+
end
|
|
344
349
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
350
|
+
# Streamable HTTP (2025-11-25) requires a single JSON-RPC message object per POST.
|
|
351
|
+
# Batched/array bodies are not supported; reject with `-32600` instead of falling through to
|
|
352
|
+
# a malformed Rack response.
|
|
353
|
+
unless body.is_a?(Hash)
|
|
354
|
+
return invalid_request_response("Invalid Request: JSON-RPC body must be a single request object")
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
|
|
358
|
+
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
|
|
359
|
+
# This matches the TypeScript and Python SDKs.
|
|
360
|
+
unless initialize_request?(body)
|
|
348
361
|
return missing_session_id_response if !@stateless && !session_id
|
|
349
362
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
elsif response?(body)
|
|
354
|
-
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
363
|
+
protocol_version_error = validate_protocol_version_header(request)
|
|
364
|
+
return protocol_version_error if protocol_version_error
|
|
365
|
+
end
|
|
355
366
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
367
|
+
if initialize_request?(body)
|
|
368
|
+
if !@stateless && session_id
|
|
369
|
+
# An `initialize` request carrying an `Mcp-Session-Id` header is either a duplicate
|
|
370
|
+
# initialization attempt against a live session, or a retry against an unknown/expired
|
|
371
|
+
# one. In the live case, reject with `-32600` so the original session is not abandoned.
|
|
372
|
+
# In the unknown/expired case, return 404 so the client retries from scratch instead
|
|
373
|
+
# of silently inheriting a fresh session under the old ID.
|
|
374
|
+
return already_initialized_response(body[:id]) if session_active?(session_id)
|
|
375
|
+
|
|
376
|
+
return session_not_found_response
|
|
359
377
|
end
|
|
378
|
+
|
|
379
|
+
handle_initialization(body_string, body)
|
|
380
|
+
elsif notification?(body)
|
|
381
|
+
dispatch_notification(body_string, session_id)
|
|
382
|
+
handle_accepted
|
|
383
|
+
elsif response?(body)
|
|
384
|
+
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
385
|
+
|
|
386
|
+
handle_response(body, session_id: session_id)
|
|
387
|
+
else
|
|
388
|
+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
|
360
389
|
end
|
|
361
390
|
rescue StandardError => e
|
|
362
391
|
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
|
@@ -377,6 +406,10 @@ module MCP
|
|
|
377
406
|
|
|
378
407
|
error_response = validate_and_touch_session(session_id)
|
|
379
408
|
return error_response if error_response
|
|
409
|
+
|
|
410
|
+
protocol_version_error = validate_protocol_version_header(request)
|
|
411
|
+
return protocol_version_error if protocol_version_error
|
|
412
|
+
|
|
380
413
|
return session_already_connected_response if get_session_stream(session_id)
|
|
381
414
|
|
|
382
415
|
setup_sse_stream(session_id)
|
|
@@ -386,6 +419,9 @@ module MCP
|
|
|
386
419
|
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
|
|
387
420
|
|
|
388
421
|
if @stateless
|
|
422
|
+
protocol_version_error = validate_protocol_version_header(request)
|
|
423
|
+
return protocol_version_error if protocol_version_error
|
|
424
|
+
|
|
389
425
|
# Stateless mode doesn't support sessions, so we can just return a success response
|
|
390
426
|
return success_response
|
|
391
427
|
end
|
|
@@ -393,6 +429,9 @@ module MCP
|
|
|
393
429
|
return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
|
|
394
430
|
return session_not_found_response unless session_exists?(session_id)
|
|
395
431
|
|
|
432
|
+
protocol_version_error = validate_protocol_version_header(request)
|
|
433
|
+
return protocol_version_error if protocol_version_error
|
|
434
|
+
|
|
396
435
|
cleanup_session(session_id)
|
|
397
436
|
|
|
398
437
|
success_response
|
|
@@ -492,9 +531,34 @@ module MCP
|
|
|
492
531
|
def parse_request_body(body_string)
|
|
493
532
|
JSON.parse(body_string, symbolize_names: true)
|
|
494
533
|
rescue JSON::ParserError, TypeError
|
|
534
|
+
raise InvalidJsonError
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def invalid_json_response
|
|
495
538
|
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
|
496
539
|
end
|
|
497
540
|
|
|
541
|
+
def initialize_request?(body)
|
|
542
|
+
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def validate_protocol_version_header(request)
|
|
546
|
+
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
|
547
|
+
return if header_value.nil?
|
|
548
|
+
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
|
549
|
+
|
|
550
|
+
supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
|
|
551
|
+
body = {
|
|
552
|
+
jsonrpc: "2.0",
|
|
553
|
+
id: nil,
|
|
554
|
+
error: {
|
|
555
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
556
|
+
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
|
|
557
|
+
},
|
|
558
|
+
}
|
|
559
|
+
[400, { "Content-Type" => "application/json" }, [body.to_json]]
|
|
560
|
+
end
|
|
561
|
+
|
|
498
562
|
def notification?(body)
|
|
499
563
|
!body[:id] && !!body[:method]
|
|
500
564
|
end
|
|
@@ -560,6 +624,15 @@ module MCP
|
|
|
560
624
|
@server.handle_json(body_string)
|
|
561
625
|
end
|
|
562
626
|
|
|
627
|
+
# If `Server#init` produced an error response (e.g., malformed JSON-RPC envelope),
|
|
628
|
+
# `mark_initialized!` was never called. Discard the orphaned session and omit
|
|
629
|
+
# the `Mcp-Session-Id` header so the client retries from a clean state instead of
|
|
630
|
+
# reusing a never-initialized ID that would later look like a duplicate `initialize`.
|
|
631
|
+
if server_session && !server_session.initialized?
|
|
632
|
+
cleanup_session(session_id)
|
|
633
|
+
session_id = nil
|
|
634
|
+
end
|
|
635
|
+
|
|
563
636
|
headers = {
|
|
564
637
|
"Content-Type" => "application/json",
|
|
565
638
|
}
|
|
@@ -694,6 +767,31 @@ module MCP
|
|
|
694
767
|
@mutex.synchronize { @sessions.key?(session_id) }
|
|
695
768
|
end
|
|
696
769
|
|
|
770
|
+
# Returns true iff a session exists and is not past its idle timeout. Expired sessions
|
|
771
|
+
# are evicted as a side effect so a live request never observes a zombie session that
|
|
772
|
+
# the reaper hasn't yet pruned. Does NOT update `last_active_at`; callers that are
|
|
773
|
+
# rejecting a request must not extend the session's lifetime.
|
|
774
|
+
def session_active?(session_id)
|
|
775
|
+
removed = nil
|
|
776
|
+
active = @mutex.synchronize do
|
|
777
|
+
next false unless (session = @sessions[session_id])
|
|
778
|
+
|
|
779
|
+
if session_expired?(session)
|
|
780
|
+
removed = cleanup_session_unsafe(session_id)
|
|
781
|
+
next false
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
true
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
if removed
|
|
788
|
+
close_stream_safely(removed[:get_sse_stream])
|
|
789
|
+
close_post_request_streams(removed)
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
active
|
|
793
|
+
end
|
|
794
|
+
|
|
697
795
|
def method_not_allowed_response
|
|
698
796
|
[405, { "Content-Type" => "application/json" }, [{ error: "Method not allowed" }.to_json]]
|
|
699
797
|
end
|
|
@@ -706,6 +804,22 @@ module MCP
|
|
|
706
804
|
[404, { "Content-Type" => "application/json" }, [{ error: "Session not found" }.to_json]]
|
|
707
805
|
end
|
|
708
806
|
|
|
807
|
+
def already_initialized_response(request_id)
|
|
808
|
+
invalid_request_response("Invalid Request: Server already initialized", request_id: request_id)
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def invalid_request_response(message, request_id: nil)
|
|
812
|
+
body = {
|
|
813
|
+
jsonrpc: "2.0",
|
|
814
|
+
id: request_id,
|
|
815
|
+
error: {
|
|
816
|
+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
817
|
+
message: message,
|
|
818
|
+
},
|
|
819
|
+
}
|
|
820
|
+
[400, { "Content-Type" => "application/json" }, [body.to_json]]
|
|
821
|
+
end
|
|
822
|
+
|
|
709
823
|
def session_already_connected_response
|
|
710
824
|
[
|
|
711
825
|
409,
|
data/lib/mcp/server.rb
CHANGED
|
@@ -497,6 +497,13 @@ module MCP
|
|
|
497
497
|
end
|
|
498
498
|
|
|
499
499
|
def init(params, session: nil)
|
|
500
|
+
# MCP spec: the initialization phase MUST be the first interaction between client and server.
|
|
501
|
+
# Reject duplicate `initialize` on an already-initialized session so the negotiated
|
|
502
|
+
# client identity and capabilities cannot be silently overwritten.
|
|
503
|
+
if session&.initialized?
|
|
504
|
+
raise RequestHandlerError.new("Invalid Request: Server already initialized", params, error_type: :invalid_request)
|
|
505
|
+
end
|
|
506
|
+
|
|
500
507
|
if params
|
|
501
508
|
if session
|
|
502
509
|
session.store_client_info(client: params[:clientInfo], capabilities: params[:capabilities])
|
|
@@ -524,6 +531,8 @@ module MCP
|
|
|
524
531
|
response_instructions = nil
|
|
525
532
|
end
|
|
526
533
|
|
|
534
|
+
session&.mark_initialized!
|
|
535
|
+
|
|
527
536
|
{
|
|
528
537
|
protocolVersion: negotiated_version,
|
|
529
538
|
capabilities: capabilities,
|
data/lib/mcp/server_session.rb
CHANGED
|
@@ -18,6 +18,19 @@ module MCP
|
|
|
18
18
|
@logging_message_notification = nil
|
|
19
19
|
@in_flight = {}
|
|
20
20
|
@in_flight_mutex = Mutex.new
|
|
21
|
+
@initialized = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether `initialize` has already completed for this session.
|
|
25
|
+
def initialized?
|
|
26
|
+
@initialized
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Called by `Server#init` after a successful `initialize` response, so subsequent
|
|
30
|
+
# `initialize` requests on the same session can be rejected per MCP spec
|
|
31
|
+
# (the initialization phase MUST be the first interaction).
|
|
32
|
+
def mark_initialized!
|
|
33
|
+
@initialized = true
|
|
21
34
|
end
|
|
22
35
|
|
|
23
36
|
# Registers a `Cancellation` token for an in-flight request.
|
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.17.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Model Context Protocol
|
|
@@ -41,6 +41,12 @@ files:
|
|
|
41
41
|
- lib/mcp/cancelled_error.rb
|
|
42
42
|
- lib/mcp/client.rb
|
|
43
43
|
- lib/mcp/client/http.rb
|
|
44
|
+
- lib/mcp/client/oauth.rb
|
|
45
|
+
- lib/mcp/client/oauth/discovery.rb
|
|
46
|
+
- lib/mcp/client/oauth/flow.rb
|
|
47
|
+
- lib/mcp/client/oauth/in_memory_storage.rb
|
|
48
|
+
- lib/mcp/client/oauth/pkce.rb
|
|
49
|
+
- lib/mcp/client/oauth/provider.rb
|
|
44
50
|
- lib/mcp/client/paginated_result.rb
|
|
45
51
|
- lib/mcp/client/stdio.rb
|
|
46
52
|
- lib/mcp/client/tool.rb
|
|
@@ -81,7 +87,7 @@ licenses:
|
|
|
81
87
|
- Apache-2.0
|
|
82
88
|
metadata:
|
|
83
89
|
allowed_push_host: https://rubygems.org
|
|
84
|
-
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.
|
|
90
|
+
changelog_uri: https://github.com/modelcontextprotocol/ruby-sdk/releases/tag/v0.17.0
|
|
85
91
|
homepage_uri: https://ruby.sdk.modelcontextprotocol.io
|
|
86
92
|
source_code_uri: https://github.com/modelcontextprotocol/ruby-sdk
|
|
87
93
|
bug_tracker_uri: https://github.com/modelcontextprotocol/ruby-sdk/issues
|