mcp 1.9.0 → 1.10.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 +3 -0
- data/lib/json_rpc_handler.rb +1 -1
- data/lib/mcp/client/http.rb +29 -5
- data/lib/mcp/client/oauth/client_credentials_provider.rb +16 -0
- data/lib/mcp/client/oauth/cross_app_access_provider.rb +19 -2
- data/lib/mcp/client/oauth/flow.rb +11 -3
- data/lib/mcp/client/oauth/jwt_client_assertion.rb +3 -2
- data/lib/mcp/client/oauth/pkce.rb +3 -2
- data/lib/mcp/client/stdio.rb +15 -3
- data/lib/mcp/client.rb +5 -1
- data/lib/mcp/instrumentation.rb +12 -7
- data/lib/mcp/mcp_param_headers.rb +332 -0
- data/lib/mcp/server/oauth/jwt_verifier.rb +34 -5
- data/lib/mcp/server/transports/stdio_transport.rb +12 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +168 -19
- data/lib/mcp/server.rb +60 -35
- data/lib/mcp/server_session.rb +23 -1
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +2 -0
- metadata +3 -3
- data/lib/mcp/client/mcp_param_headers.rb +0 -242
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "set"
|
|
5
|
+
require "timeout"
|
|
5
6
|
require_relative "../../result_type"
|
|
6
7
|
require_relative "../../transport"
|
|
7
8
|
|
|
@@ -188,6 +189,13 @@ module MCP
|
|
|
188
189
|
# @param server_to_client_request_timeout [Numeric] seconds a server-to-client request waits for its
|
|
189
190
|
# response before the transport stops waiting and raises `MCP::Server::RequestTimeoutError`.
|
|
190
191
|
# Defaults to `DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT` (600); individual calls override it with `timeout:`.
|
|
192
|
+
# @param stream_write_timeout [Numeric, nil] seconds a notification, a server-to-client request, a listen
|
|
193
|
+
# acknowledgement or a keepalive may take to be written to an SSE stream before the stream is dropped as stalled.
|
|
194
|
+
# These writes are made on behalf of every stream in turn, so a peer that stops reading would otherwise hold
|
|
195
|
+
# the writer, and the streams after it, for as long as its buffers stay full. The response of a request is not bounded:
|
|
196
|
+
# it is written on that request's own thread and may be large. The bound is per stream, so several stalled streams delay
|
|
197
|
+
# a notification by the sum of their waits, once each. Closing a stream is given the same number of seconds, once.
|
|
198
|
+
# Defaults to `DEFAULT_STREAM_WRITE_TIMEOUT` (10); pass `nil` to wait without limit.
|
|
191
199
|
# @param token_verifier [#verify, nil] enables built-in OAuth 2.1 bearer enforcement: anything responding to
|
|
192
200
|
# `verify(token) -> MCP::Server::OAuth::AccessToken | nil` (see `MCP::Server::OAuth::TokenVerifier` for
|
|
193
201
|
# the contract and the built-in JWT/introspection implementations). Every POST, GET, and DELETE is verified
|
|
@@ -227,6 +235,7 @@ module MCP
|
|
|
227
235
|
listen_keepalive_interval: DEFAULT_LISTEN_KEEPALIVE_INTERVAL,
|
|
228
236
|
serve_subscriptions_listen: true,
|
|
229
237
|
server_to_client_request_timeout: DEFAULT_SERVER_TO_CLIENT_REQUEST_TIMEOUT,
|
|
238
|
+
stream_write_timeout: DEFAULT_STREAM_WRITE_TIMEOUT,
|
|
230
239
|
token_verifier: nil,
|
|
231
240
|
required_scopes: [],
|
|
232
241
|
resource_metadata: nil,
|
|
@@ -325,6 +334,15 @@ module MCP
|
|
|
325
334
|
|
|
326
335
|
@server_to_client_request_timeout = server_to_client_request_timeout
|
|
327
336
|
|
|
337
|
+
unless stream_write_timeout.nil? || (stream_write_timeout.is_a?(Numeric) && stream_write_timeout.positive?)
|
|
338
|
+
raise ArgumentError, "stream_write_timeout must be a positive number or nil"
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
@stream_write_timeout = stream_write_timeout
|
|
342
|
+
|
|
343
|
+
# Keyed by identity and weak, so a stream is not kept alive for having been recorded here.
|
|
344
|
+
@streams_with_abandoned_close = ObjectSpace::WeakMap.new
|
|
345
|
+
|
|
328
346
|
if token_verifier
|
|
329
347
|
@oauth_authenticator = OAuth::Authenticator.new(
|
|
330
348
|
token_verifier: token_verifier,
|
|
@@ -358,6 +376,14 @@ module MCP
|
|
|
358
376
|
REQUIRED_POST_ACCEPT_TYPES_JSON = ["application/json"].freeze
|
|
359
377
|
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
|
|
360
378
|
STREAM_WRITE_ERRORS = [IOError, Errno::EPIPE, Errno::ECONNRESET].freeze
|
|
379
|
+
|
|
380
|
+
# Raised from a write the transport pushes onto an SSE stream when it has not finished within `stream_write_timeout:` seconds.
|
|
381
|
+
# An `IOError`, so every path that drops a stream on a failed write drops this one the same way.
|
|
382
|
+
class StreamWriteTimeoutError < IOError; end
|
|
383
|
+
|
|
384
|
+
# A write blocks only once the peer's socket buffers are full, which takes a reader already hundreds of kilobytes behind;
|
|
385
|
+
# one that still has not taken the frame after this long is dropped whether it stopped reading or is only that slow.
|
|
386
|
+
DEFAULT_STREAM_WRITE_TIMEOUT = 10
|
|
361
387
|
SESSION_REAP_INTERVAL = 60
|
|
362
388
|
# Seconds `close` waits for a reap in progress to finish closing its streams before it kills the reaper.
|
|
363
389
|
REAPER_JOIN_TIMEOUT = 5
|
|
@@ -509,6 +535,20 @@ module MCP
|
|
|
509
535
|
end
|
|
510
536
|
end
|
|
511
537
|
|
|
538
|
+
# Sessions a server-wide log broadcast is offered to, each applying its own level or the server default.
|
|
539
|
+
# Public only so that `Server#notify_log_message` can call it. Custom transports may implement this hook;
|
|
540
|
+
# returning `nil` keeps the server-wide filter.
|
|
541
|
+
#
|
|
542
|
+
# @api private
|
|
543
|
+
# @return [Array<ServerSession>]
|
|
544
|
+
def logging_sessions
|
|
545
|
+
@mutex.synchronize do
|
|
546
|
+
@sessions.values.filter_map do |session|
|
|
547
|
+
session[:server_session] if session[:get_sse_stream]
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
512
552
|
def send_notification(method, params = nil, session_id: nil, related_request_id: nil)
|
|
513
553
|
# `subscriptions/listen` streams (SEP-2575) receive matching change notifications regardless of the delivery below:
|
|
514
554
|
# a resource updated by one session's tool call changed globally, so modern subscribers hear about it too.
|
|
@@ -619,7 +659,7 @@ module MCP
|
|
|
619
659
|
# Writes a notification to an SSE stream without holding `@mutex`. On a write error,
|
|
620
660
|
# drops the broken stream and returns false; on success returns true.
|
|
621
661
|
def write_notification(stream, notification, session_id, related_request_id)
|
|
622
|
-
|
|
662
|
+
push_to_stream(stream, notification)
|
|
623
663
|
true
|
|
624
664
|
rescue *STREAM_WRITE_ERRORS => e
|
|
625
665
|
MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to send notification" })
|
|
@@ -722,7 +762,7 @@ module MCP
|
|
|
722
762
|
sent = false
|
|
723
763
|
if stream
|
|
724
764
|
begin
|
|
725
|
-
|
|
765
|
+
push_to_stream(stream, request)
|
|
726
766
|
sent = true
|
|
727
767
|
rescue *STREAM_WRITE_ERRORS
|
|
728
768
|
drop_broken_stream(session_id, stream, related_request_id)
|
|
@@ -866,6 +906,41 @@ module MCP
|
|
|
866
906
|
stream.flush
|
|
867
907
|
end
|
|
868
908
|
|
|
909
|
+
# `send_to_stream` and `send_ping_to_stream` for the frames the transport pushes onto a stream on its own: a notification,
|
|
910
|
+
# a server-to-client request, a listen acknowledgement, a keepalive. One thread writes them to stream after stream,
|
|
911
|
+
# and a keepalive write holds its stream's write mutex, so a peer that stopped reading would stall the notifications of
|
|
912
|
+
# every other client, and each thread that sends one, for as long as its buffers stay full.
|
|
913
|
+
# The write is given `stream_write_timeout:` seconds; past that it fails as a write to a broken stream does, and the caller drops the stream.
|
|
914
|
+
#
|
|
915
|
+
# The deadline may cut a frame short, so the stream is closed here, before the error leaves: a listen stream is written under its write mutex,
|
|
916
|
+
# which the error releases on its way out, ahead of the caller's removal of the entry, and a writer waiting on that mutex would otherwise put
|
|
917
|
+
# its frame after the cut one. Closed first, every later write fails as on any closed stream. The entry of a listen stream, passed as `subscription`,
|
|
918
|
+
# is marked closed as well, still under its write mutex, so those writers skip it even when the close itself had to be abandoned.
|
|
919
|
+
#
|
|
920
|
+
#
|
|
921
|
+
# The deadline interrupts the write with the exception `Timeout` raises on its own, which no `rescue` of an `IOError` or a `StandardError` inside
|
|
922
|
+
# a stream implementation catches, and becomes `StreamWriteTimeoutError` only here, where it leaves the write.
|
|
923
|
+
def push_to_stream(stream, data, subscription: nil)
|
|
924
|
+
within_stream_write_timeout(stream, subscription) { send_to_stream(stream, data) }
|
|
925
|
+
end
|
|
926
|
+
|
|
927
|
+
def push_ping_to_stream(stream, subscription: nil)
|
|
928
|
+
within_stream_write_timeout(stream, subscription) { send_ping_to_stream(stream) }
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
def within_stream_write_timeout(stream, subscription, &block)
|
|
932
|
+
return yield unless @stream_write_timeout
|
|
933
|
+
|
|
934
|
+
begin
|
|
935
|
+
Timeout.timeout(@stream_write_timeout, &block)
|
|
936
|
+
rescue Timeout::Error
|
|
937
|
+
subscription[:closed] = true if subscription
|
|
938
|
+
close_stream_safely(stream)
|
|
939
|
+
|
|
940
|
+
raise StreamWriteTimeoutError, "SSE stream write did not finish within #{@stream_write_timeout} seconds"
|
|
941
|
+
end
|
|
942
|
+
end
|
|
943
|
+
|
|
869
944
|
# Serves one request of the stateless modern lifecycle (MCP 2026-07-28, SEP-2575):
|
|
870
945
|
# a single POST/JSON exchange with no session. The modern path never consults
|
|
871
946
|
# `@stateless`, `@sessions`, or `@enable_json_response`, and never issues or accepts
|
|
@@ -905,9 +980,9 @@ module MCP
|
|
|
905
980
|
message: "Unsupported protocol version",
|
|
906
981
|
data: {
|
|
907
982
|
supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
|
|
908
|
-
requested: header_version,
|
|
983
|
+
requested: printable_value(header_version),
|
|
909
984
|
},
|
|
910
|
-
id: body[:id],
|
|
985
|
+
id: echoable_id(body[:id]),
|
|
911
986
|
)
|
|
912
987
|
end
|
|
913
988
|
|
|
@@ -916,13 +991,20 @@ module MCP
|
|
|
916
991
|
status: 400,
|
|
917
992
|
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
918
993
|
message: "Bad Request: Mcp-Session-Id is not accepted in the modern lifecycle",
|
|
919
|
-
id: body[:id],
|
|
994
|
+
id: echoable_id(body[:id]),
|
|
920
995
|
)
|
|
921
996
|
end
|
|
922
997
|
|
|
923
998
|
mismatch_error = validate_modern_headers(request, body, header_version)
|
|
924
999
|
return mismatch_error if mismatch_error
|
|
925
1000
|
|
|
1001
|
+
# SEP-2243 custom headers: the `Mcp-Param-*` headers of a `tools/call` are checked against the body here,
|
|
1002
|
+
# before dispatch, so a disagreement is refused without the tool ever running.
|
|
1003
|
+
if body[:method] == Methods::TOOLS_CALL
|
|
1004
|
+
param_error = validate_mcp_param_headers(request, body)
|
|
1005
|
+
return param_error if param_error
|
|
1006
|
+
end
|
|
1007
|
+
|
|
926
1008
|
# `subscriptions/listen` is a long-lived notification stream served at the transport layer;
|
|
927
1009
|
# it never dispatches through `Server#handle`. A transport constructed with
|
|
928
1010
|
# `serve_subscriptions_listen: false` skips the interception, so the method falls through
|
|
@@ -983,7 +1065,7 @@ module MCP
|
|
|
983
1065
|
meta_version = params.is_a?(Hash) && params[:_meta].is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
|
|
984
1066
|
if meta_version && meta_version != header_version
|
|
985
1067
|
return header_mismatch_response(
|
|
986
|
-
"MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
|
|
1068
|
+
"MCP-Protocol-Version header value '#{printable_value(header_version)}' does not match body value '#{printable_value(meta_version)}'",
|
|
987
1069
|
body[:id],
|
|
988
1070
|
)
|
|
989
1071
|
end
|
|
@@ -1002,7 +1084,7 @@ module MCP
|
|
|
1002
1084
|
end
|
|
1003
1085
|
if method_header != body[:method]
|
|
1004
1086
|
return header_mismatch_response(
|
|
1005
|
-
"Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
|
|
1087
|
+
"Mcp-Method header value '#{printable_value(method_header)}' does not match body value '#{printable_value(body[:method])}'",
|
|
1006
1088
|
body[:id],
|
|
1007
1089
|
)
|
|
1008
1090
|
end
|
|
@@ -1013,7 +1095,7 @@ module MCP
|
|
|
1013
1095
|
if body_name
|
|
1014
1096
|
if name_header.to_s.empty?
|
|
1015
1097
|
return header_mismatch_response(
|
|
1016
|
-
"Mcp-Name header is required for `#{body[:method]}`",
|
|
1098
|
+
"Mcp-Name header is required for `#{printable_value(body[:method])}`",
|
|
1017
1099
|
body[:id],
|
|
1018
1100
|
)
|
|
1019
1101
|
end
|
|
@@ -1021,7 +1103,7 @@ module MCP
|
|
|
1021
1103
|
decoded_name = decode_header_value(name_header)
|
|
1022
1104
|
if decoded_name != body_name
|
|
1023
1105
|
return header_mismatch_response(
|
|
1024
|
-
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
|
|
1106
|
+
"Mcp-Name header value '#{printable_value(decoded_name)}' does not match body value '#{printable_value(body_name)}'",
|
|
1025
1107
|
body[:id],
|
|
1026
1108
|
)
|
|
1027
1109
|
end
|
|
@@ -1031,6 +1113,39 @@ module MCP
|
|
|
1031
1113
|
nil
|
|
1032
1114
|
end
|
|
1033
1115
|
|
|
1116
|
+
# Enforces the SEP-2243 server behavior for custom headers (`-32020`, HTTP 400) on a modern `tools/call`: each
|
|
1117
|
+
# `x-mcp-header` parameter of the called tool must have its `Mcp-Param-{Name}` header present exactly when the body
|
|
1118
|
+
# carries a value for it, agreeing with that value once a `=?base64?...?=` sentinel is decoded, and a malformed
|
|
1119
|
+
# sentinel is refused outright. The comparison runs before the tool does, on the schema the server itself serves
|
|
1120
|
+
# in `tools/list`, so the body a handler acts on is the one an intermediary routed on. A tool the server does not
|
|
1121
|
+
# know, or one whose declarations are invalid (a conforming client drops such a tool and sends no headers for it),
|
|
1122
|
+
# is left to the dispatcher, and headers for parameters no declaration names are ignored, as the spec asks of any
|
|
1123
|
+
# server that does not recognize them. The refusal names the header and the argument, not their values.
|
|
1124
|
+
#
|
|
1125
|
+
# Rack folds header names into `HTTP_` keys with `-` as `_`, so `Mcp-Param-Region-A` and `Mcp-Param-Region_A` land
|
|
1126
|
+
# on one key; a token that differs only in that way is the schema author's choice, and the value still has to
|
|
1127
|
+
# agree with the body.
|
|
1128
|
+
def validate_mcp_param_headers(request, body)
|
|
1129
|
+
params = body[:params]
|
|
1130
|
+
return unless params.is_a?(Hash)
|
|
1131
|
+
|
|
1132
|
+
tool = @server.tools[params[:name]]
|
|
1133
|
+
return unless tool
|
|
1134
|
+
|
|
1135
|
+
scan = McpParamHeaders.scan(tool.input_schema.to_h)
|
|
1136
|
+
return unless scan[:valid] && !scan[:declarations].empty?
|
|
1137
|
+
|
|
1138
|
+
arguments = params[:arguments] || {}
|
|
1139
|
+
return unless arguments.is_a?(Hash)
|
|
1140
|
+
|
|
1141
|
+
message = McpParamHeaders.mismatch(scan[:declarations], arguments) do |header_name|
|
|
1142
|
+
header_bytes(request, "HTTP_#{header_name.upcase.tr("-", "_")}")
|
|
1143
|
+
end
|
|
1144
|
+
return unless message
|
|
1145
|
+
|
|
1146
|
+
header_mismatch_response(message, body[:id])
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1034
1149
|
# Serves `subscriptions/listen` (SEP-2575): opens a long-lived SSE stream whose first message is
|
|
1035
1150
|
# `notifications/subscriptions/acknowledged` with the subset of requested notification types
|
|
1036
1151
|
# the server agreed to honor. Notifications delivered on the stream carry `io.modelcontextprotocol/subscriptionId`
|
|
@@ -1135,6 +1250,14 @@ module MCP
|
|
|
1135
1250
|
false
|
|
1136
1251
|
end
|
|
1137
1252
|
|
|
1253
|
+
# The id a refusal built here can echo: an Integer, or a String that is valid UTF-8. A String holding other
|
|
1254
|
+
# bytes would make the body's `to_json` raise, so the refusal carries no id, as for an id it could not read.
|
|
1255
|
+
def echoable_id(id)
|
|
1256
|
+
return id if id.is_a?(Integer) || (id.is_a?(String) && id.valid_encoding?)
|
|
1257
|
+
|
|
1258
|
+
nil
|
|
1259
|
+
end
|
|
1260
|
+
|
|
1138
1261
|
def listen_subscriptions_full?
|
|
1139
1262
|
return false unless @max_listen_subscriptions
|
|
1140
1263
|
|
|
@@ -1222,7 +1345,7 @@ module MCP
|
|
|
1222
1345
|
acknowledged = subscription[:write_mutex].synchronize do
|
|
1223
1346
|
next false if subscription[:closed]
|
|
1224
1347
|
|
|
1225
|
-
|
|
1348
|
+
push_to_stream(stream, acknowledgement, subscription: subscription)
|
|
1226
1349
|
|
|
1227
1350
|
# Set on the entry itself, not through the registry: a concurrent close may already have cleared the registry
|
|
1228
1351
|
# while its result write waits on this mutex, and that write must still find the stream acknowledged.
|
|
@@ -1303,7 +1426,7 @@ module MCP
|
|
|
1303
1426
|
subscription[:write_mutex].synchronize do
|
|
1304
1427
|
next if subscription[:closed]
|
|
1305
1428
|
|
|
1306
|
-
|
|
1429
|
+
push_ping_to_stream(subscription[:stream], subscription: subscription)
|
|
1307
1430
|
end
|
|
1308
1431
|
end
|
|
1309
1432
|
|
|
@@ -1445,7 +1568,7 @@ module MCP
|
|
|
1445
1568
|
subscription[:write_mutex].synchronize do
|
|
1446
1569
|
next if subscription[:closed]
|
|
1447
1570
|
|
|
1448
|
-
|
|
1571
|
+
push_to_stream(subscription[:stream], notification, subscription: subscription)
|
|
1449
1572
|
end
|
|
1450
1573
|
rescue *STREAM_WRITE_ERRORS => e
|
|
1451
1574
|
MCP.configuration.exception_reporter.call(
|
|
@@ -1535,6 +1658,10 @@ module MCP
|
|
|
1535
1658
|
# The stream is left open for the caller to close.
|
|
1536
1659
|
def write_listen_closing_result(subscription)
|
|
1537
1660
|
subscription[:write_mutex].synchronize do
|
|
1661
|
+
# Already marked by a write that ran into its deadline: the last frame on the stream may be cut short,
|
|
1662
|
+
# and the stream may still be open because its close was abandoned, so the result must not follow it.
|
|
1663
|
+
next if subscription[:closed]
|
|
1664
|
+
|
|
1538
1665
|
subscription[:closed] = true
|
|
1539
1666
|
|
|
1540
1667
|
# A stream whose acknowledgement was never written gets no result either: SEP-2575 makes
|
|
@@ -1571,12 +1698,21 @@ module MCP
|
|
|
1571
1698
|
end
|
|
1572
1699
|
end
|
|
1573
1700
|
|
|
1701
|
+
# A client-supplied value for an error message or `data` member. A header value reaches the transport as
|
|
1702
|
+
# ASCII-8BIT from a Rack server, or tagged UTF-8 from a test request or a framework, and a body value comes
|
|
1703
|
+
# out of the JSON parser; a value holding bytes that are not valid UTF-8 would make the response's `to_json`
|
|
1704
|
+
# raise and turn the intended 400 into a 500 with an exception report. Such bytes are replaced with U+FFFD;
|
|
1705
|
+
# a valid value comes through unchanged.
|
|
1706
|
+
def printable_value(value)
|
|
1707
|
+
value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
|
|
1708
|
+
end
|
|
1709
|
+
|
|
1574
1710
|
def header_mismatch_response(message, id)
|
|
1575
1711
|
json_rpc_error_response(
|
|
1576
1712
|
status: 400,
|
|
1577
1713
|
code: ErrorCodes::HEADER_MISMATCH,
|
|
1578
1714
|
message: "Header mismatch: #{message}",
|
|
1579
|
-
id: id,
|
|
1715
|
+
id: echoable_id(id),
|
|
1580
1716
|
)
|
|
1581
1717
|
end
|
|
1582
1718
|
|
|
@@ -1653,7 +1789,7 @@ module MCP
|
|
|
1653
1789
|
if header_version && MCP::Configuration.modern_protocol_version?(header_version)
|
|
1654
1790
|
return invalid_request_response(
|
|
1655
1791
|
"Invalid Request: the session already negotiated the legacy lifecycle via `initialize`",
|
|
1656
|
-
request_id: body[:id],
|
|
1792
|
+
request_id: echoable_id(body[:id]),
|
|
1657
1793
|
)
|
|
1658
1794
|
end
|
|
1659
1795
|
|
|
@@ -1699,7 +1835,7 @@ module MCP
|
|
|
1699
1835
|
session = @mutex.synchronize { @sessions[session_id] }
|
|
1700
1836
|
return session_not_found_response unless session && session_principal_matches?(session, request)
|
|
1701
1837
|
|
|
1702
|
-
return already_initialized_response(body[:id])
|
|
1838
|
+
return already_initialized_response(echoable_id(body[:id]))
|
|
1703
1839
|
end
|
|
1704
1840
|
|
|
1705
1841
|
return session_not_found_response
|
|
@@ -1834,8 +1970,21 @@ module MCP
|
|
|
1834
1970
|
removed[:post_request_streams]&.each_value { |stream| streams_to_close << stream }
|
|
1835
1971
|
end
|
|
1836
1972
|
|
|
1973
|
+
# A close can block on the same full buffers a write does (a TLS stream writes its closing alert, a buffered one flushes),
|
|
1974
|
+
# and it runs on the threads that deliver to every stream, in places under a stream's write mutex,
|
|
1975
|
+
# so it gets `stream_write_timeout:` seconds as well. One that has not finished by then is abandoned,
|
|
1976
|
+
# and not tried again: the path that drops a stream after its write timed out closes it a second time,
|
|
1977
|
+
# which would wait out the same block once more.
|
|
1837
1978
|
def close_stream_safely(stream)
|
|
1838
|
-
stream
|
|
1979
|
+
return unless stream
|
|
1980
|
+
return stream.close unless @stream_write_timeout
|
|
1981
|
+
return if @streams_with_abandoned_close.key?(stream)
|
|
1982
|
+
|
|
1983
|
+
begin
|
|
1984
|
+
Timeout.timeout(@stream_write_timeout) { stream.close }
|
|
1985
|
+
rescue Timeout::Error
|
|
1986
|
+
@streams_with_abandoned_close[stream] = true
|
|
1987
|
+
end
|
|
1839
1988
|
rescue StandardError
|
|
1840
1989
|
# Ignore close-related errors from already closed/broken streams.
|
|
1841
1990
|
end
|
|
@@ -1966,7 +2115,7 @@ module MCP
|
|
|
1966
2115
|
status: 413,
|
|
1967
2116
|
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
1968
2117
|
message: "Payload too large: initialize request #{reason}",
|
|
1969
|
-
id: request_id,
|
|
2118
|
+
id: echoable_id(request_id),
|
|
1970
2119
|
)
|
|
1971
2120
|
end
|
|
1972
2121
|
|
|
@@ -2055,7 +2204,7 @@ module MCP
|
|
|
2055
2204
|
json_rpc_error_response(
|
|
2056
2205
|
status: 400,
|
|
2057
2206
|
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
|
2058
|
-
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
|
|
2207
|
+
message: "Bad Request: Unsupported protocol version: #{printable_value(header_value)}. Supported versions: #{supported}",
|
|
2059
2208
|
)
|
|
2060
2209
|
end
|
|
2061
2210
|
|
|
@@ -2657,7 +2806,7 @@ module MCP
|
|
|
2657
2806
|
def send_keepalive_ping(session_id, stream)
|
|
2658
2807
|
return unless session_holds_stream?(session_id, stream)
|
|
2659
2808
|
|
|
2660
|
-
|
|
2809
|
+
push_ping_to_stream(stream)
|
|
2661
2810
|
rescue *STREAM_WRITE_ERRORS => e
|
|
2662
2811
|
MCP.configuration.exception_reporter.call(
|
|
2663
2812
|
e,
|
data/lib/mcp/server.rb
CHANGED
|
@@ -447,6 +447,13 @@ module MCP
|
|
|
447
447
|
# Use stderr or OpenTelemetry instead.
|
|
448
448
|
def notify_log_message(data:, level:, logger: nil)
|
|
449
449
|
return unless @transport
|
|
450
|
+
|
|
451
|
+
if @transport.respond_to?(:logging_sessions) && (sessions = @transport.logging_sessions)
|
|
452
|
+
sessions.each do |session|
|
|
453
|
+
session.notify_log_message(data: data, level: level, logger: logger)
|
|
454
|
+
end
|
|
455
|
+
return
|
|
456
|
+
end
|
|
450
457
|
return unless logging_message_notification&.should_notify?(level)
|
|
451
458
|
|
|
452
459
|
params = { "data" => data, "level" => level }
|
|
@@ -733,9 +740,9 @@ module MCP
|
|
|
733
740
|
|
|
734
741
|
handler = @handlers[method]
|
|
735
742
|
unless handler
|
|
736
|
-
instrument_call("unsupported_method", server_context: { request: request }) do
|
|
743
|
+
instrument_call("unsupported_method", server_context: { request: request }) do |instrumentation_data|
|
|
737
744
|
client = session&.client || @client
|
|
738
|
-
|
|
745
|
+
instrumentation_data.merge!(client: client) if client
|
|
739
746
|
end
|
|
740
747
|
return
|
|
741
748
|
end
|
|
@@ -798,7 +805,7 @@ module MCP
|
|
|
798
805
|
method,
|
|
799
806
|
server_context: { request: request },
|
|
800
807
|
exception_already_reported: ->(e) { reported_exception.equal?(e) },
|
|
801
|
-
) do
|
|
808
|
+
) do |instrumentation_data|
|
|
802
809
|
refuse_non_object_meta!(params)
|
|
803
810
|
envelope = lift_request_envelope(params, method: method, session: session)
|
|
804
811
|
|
|
@@ -817,7 +824,7 @@ module MCP
|
|
|
817
824
|
when Methods::INITIALIZE
|
|
818
825
|
init(params, session: session)
|
|
819
826
|
when Methods::RESOURCES_READ
|
|
820
|
-
contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
|
|
827
|
+
contents = read_resource_contents(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
|
|
821
828
|
|
|
822
829
|
# An SEP-2322 `input_required` result must not be wrapped as `contents` or stamped with SEP-2549 cache hints.
|
|
823
830
|
contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
|
|
@@ -827,9 +834,9 @@ module MCP
|
|
|
827
834
|
|
|
828
835
|
subscription_result(handler_result)
|
|
829
836
|
when Methods::TOOLS_CALL
|
|
830
|
-
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
|
|
837
|
+
call_tool(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
|
|
831
838
|
when Methods::PROMPTS_GET
|
|
832
|
-
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
|
|
839
|
+
get_prompt(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
|
|
833
840
|
when Methods::COMPLETION_COMPLETE
|
|
834
841
|
complete(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
|
|
835
842
|
when Methods::LOGGING_SET_LEVEL
|
|
@@ -838,10 +845,10 @@ module MCP
|
|
|
838
845
|
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope, auth_info: auth_info)
|
|
839
846
|
end
|
|
840
847
|
client = session&.client || @client
|
|
841
|
-
|
|
848
|
+
instrumentation_data.merge!(client: client) if client
|
|
842
849
|
|
|
843
850
|
if cancellation&.cancelled?
|
|
844
|
-
|
|
851
|
+
instrumentation_data.merge!(cancelled: true, cancellation_reason: cancellation.reason)
|
|
845
852
|
next JsonRpcHandler::NO_RESPONSE
|
|
846
853
|
end
|
|
847
854
|
|
|
@@ -851,6 +858,7 @@ module MCP
|
|
|
851
858
|
result = if envelope.nil? && @input_required_legacy_shim && session
|
|
852
859
|
run_legacy_input_required_shim(
|
|
853
860
|
result,
|
|
861
|
+
instrumentation_data: instrumentation_data,
|
|
854
862
|
method: method,
|
|
855
863
|
params: params,
|
|
856
864
|
session: session,
|
|
@@ -858,7 +866,7 @@ module MCP
|
|
|
858
866
|
cancellation: cancellation,
|
|
859
867
|
)
|
|
860
868
|
else
|
|
861
|
-
serialize_input_required_result(result, envelope: envelope, request: params, method: method)
|
|
869
|
+
serialize_input_required_result(result, instrumentation_data: instrumentation_data, envelope: envelope, request: params, method: method)
|
|
862
870
|
end
|
|
863
871
|
end
|
|
864
872
|
|
|
@@ -884,25 +892,25 @@ module MCP
|
|
|
884
892
|
|
|
885
893
|
result
|
|
886
894
|
rescue CancelledError => e
|
|
887
|
-
|
|
895
|
+
instrumentation_data.merge!(cancelled: true, cancellation_reason: e.reason)
|
|
888
896
|
next JsonRpcHandler::NO_RESPONSE
|
|
889
897
|
rescue OAuth::InsufficientScopeError => e
|
|
890
898
|
# A handler's `require_scopes!` failure is a client authorization error, not a server fault:
|
|
891
899
|
# surface it as an invalid-request JSON-RPC error whose message names the missing scopes.
|
|
892
900
|
# HTTP-level 403 challenges remain the transport `required_scopes` gate's job.
|
|
893
901
|
report_exception(e, { request: request })
|
|
894
|
-
|
|
902
|
+
instrumentation_data.merge!(error: :insufficient_scope)
|
|
895
903
|
converted = RequestHandlerError.new(e.message, request, error_type: :invalid_request, original_error: e)
|
|
896
904
|
reported_exception = converted
|
|
897
905
|
raise converted
|
|
898
906
|
rescue RequestHandlerError => e
|
|
899
907
|
report_exception(e.original_error || e, { request: request })
|
|
900
|
-
|
|
908
|
+
instrumentation_data.merge!(error: e.error_type)
|
|
901
909
|
reported_exception = e
|
|
902
910
|
raise e
|
|
903
911
|
rescue => e
|
|
904
912
|
report_exception(e, { request: request })
|
|
905
|
-
|
|
913
|
+
instrumentation_data.merge!(error: :internal_error)
|
|
906
914
|
wrapped = RequestHandlerError.new("Internal error handling #{method} request", request, original_error: e)
|
|
907
915
|
reported_exception = wrapped
|
|
908
916
|
raise wrapped
|
|
@@ -998,7 +1006,7 @@ module MCP
|
|
|
998
1006
|
# a final result. The capability gate enforces the SEP-2575 rule that servers MUST NOT rely on
|
|
999
1007
|
# (or embed requests for) capabilities the client did not declare, and reports every missing capability at
|
|
1000
1008
|
# once so the client sees the full set.
|
|
1001
|
-
def serialize_input_required_result(result, envelope:, request:, method:)
|
|
1009
|
+
def serialize_input_required_result(result, instrumentation_data:, envelope:, request:, method:)
|
|
1002
1010
|
if envelope.nil?
|
|
1003
1011
|
raise RequestHandlerError.new(
|
|
1004
1012
|
"input_required results require the 2026-07-28 stateless lifecycle (SEP-2322)",
|
|
@@ -1010,7 +1018,7 @@ module MCP
|
|
|
1010
1018
|
missing = result.missing_client_capabilities(envelope.client_capabilities)
|
|
1011
1019
|
raise MissingRequiredClientCapabilityError.new(missing, request) unless missing.empty?
|
|
1012
1020
|
|
|
1013
|
-
|
|
1021
|
+
instrumentation_data.merge!(input_required: true)
|
|
1014
1022
|
serialized = result.to_h
|
|
1015
1023
|
|
|
1016
1024
|
if @request_state_security && serialized[:requestState]
|
|
@@ -1039,7 +1047,7 @@ module MCP
|
|
|
1039
1047
|
# re-runs with `inputResponses`/`requestState` merged into the original params - the same deterministic replay
|
|
1040
1048
|
# contract the modern client driver follows. The `requestState` round-trips in-process as the raw value
|
|
1041
1049
|
# the handler wrote; `RequestStateSecurity` sealing is wire hardening and does not apply.
|
|
1042
|
-
def run_legacy_input_required_shim(result, method:, params:, session:, related_request_id:, cancellation:)
|
|
1050
|
+
def run_legacy_input_required_shim(result, instrumentation_data:, method:, params:, session:, related_request_id:, cancellation:)
|
|
1043
1051
|
rounds = 0
|
|
1044
1052
|
|
|
1045
1053
|
loop do
|
|
@@ -1071,6 +1079,7 @@ module MCP
|
|
|
1071
1079
|
result = redispatch_mrtr_method(
|
|
1072
1080
|
method,
|
|
1073
1081
|
retry_params,
|
|
1082
|
+
instrumentation_data: instrumentation_data,
|
|
1074
1083
|
session: session,
|
|
1075
1084
|
related_request_id: related_request_id,
|
|
1076
1085
|
cancellation: cancellation,
|
|
@@ -1104,14 +1113,14 @@ module MCP
|
|
|
1104
1113
|
|
|
1105
1114
|
# Re-runs the handler of one of the three MRTR-capable methods for
|
|
1106
1115
|
# the legacy shim. Legacy wire, so no envelope is threaded.
|
|
1107
|
-
def redispatch_mrtr_method(method, params, session:, related_request_id:, cancellation:)
|
|
1116
|
+
def redispatch_mrtr_method(method, params, instrumentation_data:, session:, related_request_id:, cancellation:)
|
|
1108
1117
|
case method
|
|
1109
1118
|
when Methods::TOOLS_CALL
|
|
1110
|
-
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
1119
|
+
call_tool(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
1111
1120
|
when Methods::PROMPTS_GET
|
|
1112
|
-
get_prompt(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
1121
|
+
get_prompt(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
1113
1122
|
when Methods::RESOURCES_READ
|
|
1114
|
-
contents = read_resource_contents(params, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
1123
|
+
contents = read_resource_contents(params, instrumentation_data: instrumentation_data, session: session, related_request_id: related_request_id, cancellation: cancellation)
|
|
1115
1124
|
contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
|
|
1116
1125
|
else
|
|
1117
1126
|
raise RequestHandlerError.new(
|
|
@@ -1382,8 +1391,11 @@ module MCP
|
|
|
1382
1391
|
raise RequestHandlerError.new("Invalid log level #{request[:level]}", request, error_type: :invalid_params)
|
|
1383
1392
|
end
|
|
1384
1393
|
|
|
1385
|
-
session
|
|
1386
|
-
|
|
1394
|
+
if session
|
|
1395
|
+
session.configure_logging(logging_message_notification)
|
|
1396
|
+
else
|
|
1397
|
+
@logging_message_notification = logging_message_notification
|
|
1398
|
+
end
|
|
1387
1399
|
|
|
1388
1400
|
{}
|
|
1389
1401
|
end
|
|
@@ -1394,21 +1406,21 @@ module MCP
|
|
|
1394
1406
|
apply_cache_metadata({ tools: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
1395
1407
|
end
|
|
1396
1408
|
|
|
1397
|
-
def call_tool(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
|
|
1409
|
+
def call_tool(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
|
|
1398
1410
|
tool_name = request[:name]
|
|
1399
1411
|
|
|
1400
1412
|
tool = tools[tool_name]
|
|
1401
1413
|
unless tool
|
|
1402
|
-
|
|
1414
|
+
instrumentation_data.merge!(tool_name: tool_name, error: :tool_not_found)
|
|
1403
1415
|
|
|
1404
1416
|
raise RequestHandlerError.new("Tool not found: #{tool_name}", request, error_type: :invalid_params)
|
|
1405
1417
|
end
|
|
1406
1418
|
|
|
1407
1419
|
arguments = request[:arguments] || {}
|
|
1408
|
-
|
|
1420
|
+
instrumentation_data.merge!(tool_name: tool_name, tool_arguments: arguments)
|
|
1409
1421
|
|
|
1410
1422
|
if tool.input_schema&.missing_required_arguments?(arguments)
|
|
1411
|
-
|
|
1423
|
+
instrumentation_data.merge!(error: :missing_required_arguments)
|
|
1412
1424
|
|
|
1413
1425
|
missing = tool.input_schema.missing_required_arguments(arguments).join(", ")
|
|
1414
1426
|
return error_tool_response("Missing required arguments: #{missing}")
|
|
@@ -1418,7 +1430,7 @@ module MCP
|
|
|
1418
1430
|
begin
|
|
1419
1431
|
tool.input_schema.validate_arguments(arguments)
|
|
1420
1432
|
rescue Tool::InputSchema::ValidationError => e
|
|
1421
|
-
|
|
1433
|
+
instrumentation_data.merge!(error: :invalid_schema)
|
|
1422
1434
|
|
|
1423
1435
|
return error_tool_response(e.message)
|
|
1424
1436
|
end
|
|
@@ -1473,11 +1485,11 @@ module MCP
|
|
|
1473
1485
|
apply_cache_metadata({ prompts: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
1474
1486
|
end
|
|
1475
1487
|
|
|
1476
|
-
def get_prompt(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
|
|
1488
|
+
def get_prompt(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
|
|
1477
1489
|
prompt_name = request[:name]
|
|
1478
1490
|
prompt = @prompts[prompt_name]
|
|
1479
1491
|
unless prompt
|
|
1480
|
-
|
|
1492
|
+
instrumentation_data.merge!(error: :prompt_not_found)
|
|
1481
1493
|
# The explicit `error_code` maps an unknown prompt to Invalid Params (-32602) rather than
|
|
1482
1494
|
# the default Internal Error (-32603), matching the `tools/call`, `resources/read`, and
|
|
1483
1495
|
# `completion/complete` siblings for the same not-found condition, while `error_type:
|
|
@@ -1490,7 +1502,7 @@ module MCP
|
|
|
1490
1502
|
)
|
|
1491
1503
|
end
|
|
1492
1504
|
|
|
1493
|
-
|
|
1505
|
+
instrumentation_data.merge!(prompt_name: prompt_name)
|
|
1494
1506
|
|
|
1495
1507
|
prompt_args = request[:arguments]
|
|
1496
1508
|
prompt.validate_arguments!(prompt_args)
|
|
@@ -1766,9 +1778,9 @@ module MCP
|
|
|
1766
1778
|
# Default `resources/read` handler: routes to class-based resources and resource templates.
|
|
1767
1779
|
# Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
|
|
1768
1780
|
# unknown URIs keep the historical no-op `[]` response instead of raising.
|
|
1769
|
-
def read_resource(request, server_context: nil)
|
|
1781
|
+
def read_resource(request, instrumentation_data:, server_context: nil)
|
|
1770
1782
|
uri = request[:uri]
|
|
1771
|
-
|
|
1783
|
+
instrumentation_data.merge!(resource_uri: uri)
|
|
1772
1784
|
|
|
1773
1785
|
# Files of directory-backed skills are looked up by exact string equality in a map built at construction,
|
|
1774
1786
|
# so request data never reaches the filesystem; a miss falls through to the routing below.
|
|
@@ -1798,7 +1810,7 @@ module MCP
|
|
|
1798
1810
|
params = template.match_uri(uri)
|
|
1799
1811
|
next unless params
|
|
1800
1812
|
|
|
1801
|
-
|
|
1813
|
+
instrumentation_data.merge!(resource_template: template.uri_template)
|
|
1802
1814
|
return normalize_resource_contents(call_template_contents(template, params, server_context))
|
|
1803
1815
|
end
|
|
1804
1816
|
|
|
@@ -1884,9 +1896,22 @@ module MCP
|
|
|
1884
1896
|
# Invokes `resources/read` via the registered handler. If the handler block opts in to `server_context:`,
|
|
1885
1897
|
# pass an `MCP::ServerContext` so the handler can observe cancellation via `server_context.cancelled?` or
|
|
1886
1898
|
# `server_context.raise_if_cancelled!`.
|
|
1887
|
-
def read_resource_contents(request, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
|
|
1899
|
+
def read_resource_contents(request, instrumentation_data:, session: nil, related_request_id: nil, cancellation: nil, envelope: nil, auth_info: nil)
|
|
1900
|
+
handler = @handlers[Methods::RESOURCES_READ]
|
|
1901
|
+
if handler == method(:read_resource)
|
|
1902
|
+
server_context = build_server_context(
|
|
1903
|
+
request: request,
|
|
1904
|
+
session: session,
|
|
1905
|
+
related_request_id: related_request_id,
|
|
1906
|
+
cancellation: cancellation,
|
|
1907
|
+
envelope: envelope,
|
|
1908
|
+
auth_info: auth_info,
|
|
1909
|
+
)
|
|
1910
|
+
return read_resource(request, instrumentation_data: instrumentation_data, server_context: server_context)
|
|
1911
|
+
end
|
|
1912
|
+
|
|
1888
1913
|
dispatch_optional_context_handler(
|
|
1889
|
-
|
|
1914
|
+
handler,
|
|
1890
1915
|
request,
|
|
1891
1916
|
session: session,
|
|
1892
1917
|
related_request_id: related_request_id,
|