mcp 1.8.1 → 1.9.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 +2 -0
- data/lib/json_rpc_handler.rb +32 -2
- data/lib/mcp/client/skill_errors.rb +45 -0
- data/lib/mcp/client/skills.rb +430 -0
- data/lib/mcp/client/stdio.rb +12 -2
- data/lib/mcp/client/tool.rb +8 -2
- data/lib/mcp/client.rb +35 -2
- data/lib/mcp/methods.rb +5 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +54 -22
- data/lib/mcp/server.rb +403 -17
- data/lib/mcp/server_session.rb +4 -4
- data/lib/mcp/skill/directory.rb +316 -0
- data/lib/mcp/skill/frontmatter.rb +262 -0
- data/lib/mcp/skill/resource_entry.rb +109 -0
- data/lib/mcp/skill.rb +522 -0
- data/lib/mcp/tool/output_schema.rb +4 -3
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -2
data/lib/mcp/client.rb
CHANGED
|
@@ -7,6 +7,8 @@ require_relative "client/oauth"
|
|
|
7
7
|
require_relative "client/stdio"
|
|
8
8
|
require_relative "client/http"
|
|
9
9
|
require_relative "client/paginated_result"
|
|
10
|
+
require_relative "client/skill_errors"
|
|
11
|
+
require_relative "client/skills"
|
|
10
12
|
require_relative "client/tool"
|
|
11
13
|
require_relative "result_type"
|
|
12
14
|
|
|
@@ -114,6 +116,9 @@ module MCP
|
|
|
114
116
|
STATE_ONLY_BACKOFF_INITIAL_SECONDS = 0.05
|
|
115
117
|
STATE_ONLY_BACKOFF_CAP_SECONDS = 0.25
|
|
116
118
|
|
|
119
|
+
# The Skills extension methods (SEP-2640): `list_skills`, `get_skill`, `read_skill_resource`, `skills_capability`.
|
|
120
|
+
include Skills
|
|
121
|
+
|
|
117
122
|
# Initializes a new MCP::Client instance.
|
|
118
123
|
#
|
|
119
124
|
# @param transport [Object] The transport object to use for communication with the server.
|
|
@@ -144,6 +149,8 @@ module MCP
|
|
|
144
149
|
# a SEP-2322 `input_required` result.
|
|
145
150
|
@input_required_handlers = {}
|
|
146
151
|
@input_required_max_rounds = input_required_max_rounds
|
|
152
|
+
# Skills extension state (SEP-2640), bound to the connection by `connect`; see `Client::Skills`.
|
|
153
|
+
@skills_state = Skills::State.new
|
|
147
154
|
end
|
|
148
155
|
|
|
149
156
|
# The user may want to access additional transport-specific methods/attributes
|
|
@@ -217,7 +224,15 @@ module MCP
|
|
|
217
224
|
|
|
218
225
|
effective_mode = resolve_connect_mode(mode, protocol_version)
|
|
219
226
|
|
|
220
|
-
|
|
227
|
+
# Skills state (SEP-2640) follows the connection: a handshake about to run starts a new generation, its
|
|
228
|
+
# result is recorded only if no newer handshake started meanwhile, and a failure clears readiness under
|
|
229
|
+
# the same condition, so a stale completion or failure changes nothing. The bundled transports answer
|
|
230
|
+
# a repeated `connect` with their cached handshake result without any I/O, so when the transport is
|
|
231
|
+
# connected and Skills is ready nothing is reset: the latches of a live connection stay put.
|
|
232
|
+
generation = @skills_state.begin_handshake unless skills_ready_on_connected_transport?
|
|
233
|
+
completed = false
|
|
234
|
+
|
|
235
|
+
result = if effective_mode == :legacy
|
|
221
236
|
transport.connect(
|
|
222
237
|
client_info: client_info,
|
|
223
238
|
protocol_version: protocol_version,
|
|
@@ -231,6 +246,11 @@ module MCP
|
|
|
231
246
|
mode: effective_mode,
|
|
232
247
|
)
|
|
233
248
|
end
|
|
249
|
+
completed = true
|
|
250
|
+
@skills_state.handshake_completed(generation, result) if generation
|
|
251
|
+
result
|
|
252
|
+
ensure
|
|
253
|
+
@skills_state.handshake_failed(generation) if generation && !completed
|
|
234
254
|
end
|
|
235
255
|
|
|
236
256
|
# Sends `server/discover` (MCP 2026-07-28, SEP-2575): sessionless capability discovery
|
|
@@ -295,10 +315,14 @@ module MCP
|
|
|
295
315
|
|
|
296
316
|
Tool.new(
|
|
297
317
|
name: tool["name"],
|
|
318
|
+
title: tool["title"],
|
|
298
319
|
description: tool["description"],
|
|
299
320
|
input_schema: tool["inputSchema"],
|
|
300
321
|
output_schema: tool["outputSchema"],
|
|
301
322
|
annotations: tool["annotations"],
|
|
323
|
+
icons: tool["icons"],
|
|
324
|
+
execution: tool["execution"],
|
|
325
|
+
meta: tool["_meta"],
|
|
302
326
|
)
|
|
303
327
|
end
|
|
304
328
|
|
|
@@ -673,10 +697,19 @@ module MCP
|
|
|
673
697
|
scan = McpParamHeaders.scan(tool["inputSchema"])
|
|
674
698
|
return false if scan[:valid]
|
|
675
699
|
|
|
676
|
-
|
|
700
|
+
name = tool["name"]
|
|
701
|
+
# Both pieces are the server's: `dump` keeps control characters and bidirectional overrides out of the log line.
|
|
702
|
+
warn("MCP::Client: excluding tool #{(name.is_a?(String) ? name : name.inspect).dump} from tools/list: #{scan[:reason].dump}")
|
|
677
703
|
true
|
|
678
704
|
end
|
|
679
705
|
|
|
706
|
+
# Whether a `connect` would only hand back the transport's cached handshake: the transport reports itself
|
|
707
|
+
# connected and Skills already recorded a handshake of this connection. A transport without connection
|
|
708
|
+
# state always starts a new generation.
|
|
709
|
+
def skills_ready_on_connected_transport?
|
|
710
|
+
transport.respond_to?(:connected?) && transport.connected? && @skills_state.ready?
|
|
711
|
+
end
|
|
712
|
+
|
|
680
713
|
# Resolves the effective SEP-2575 lifecycle mode for `connect`:
|
|
681
714
|
#
|
|
682
715
|
# - An explicit `protocol_version` from a legacy generation pins the legacy handshake without a probe,
|
data/lib/mcp/methods.rb
CHANGED
|
@@ -22,6 +22,11 @@ module MCP
|
|
|
22
22
|
RESOURCES_SUBSCRIBE = "resources/subscribe"
|
|
23
23
|
RESOURCES_UNSUBSCRIBE = "resources/unsubscribe"
|
|
24
24
|
|
|
25
|
+
# Skills extension (SEP-2640): registered only when a server configures skills,
|
|
26
|
+
# so an unconfigured server answers `-32601` rather than a capability error.
|
|
27
|
+
SKILLS_LIST = "skills/list"
|
|
28
|
+
SKILLS_GET = "skills/get"
|
|
29
|
+
|
|
25
30
|
TOOLS_CALL = "tools/call"
|
|
26
31
|
TOOLS_LIST = "tools/list"
|
|
27
32
|
|
|
@@ -476,6 +476,9 @@ module MCP
|
|
|
476
476
|
# with a `StandardError` does not stop the teardown below: `join` re-raises the exception a thread died with,
|
|
477
477
|
# which is not this method's to raise. A reaper still alive after the wait is killed as before, leaving
|
|
478
478
|
# the streams of that reap it had not closed yet as they were.
|
|
479
|
+
# A `close` running on the reaper thread itself, reached from a stream's `close` or from the exception reporter
|
|
480
|
+
# during a reap, neither joins nor kills it: the join would fail on its own thread and the kill would end
|
|
481
|
+
# this teardown with the reap that called it. The reaper leaves its loop on `@closing` once that reap returns.
|
|
479
482
|
def close
|
|
480
483
|
reaper = @mutex.synchronize do
|
|
481
484
|
@closing = true
|
|
@@ -483,13 +486,15 @@ module MCP
|
|
|
483
486
|
@reaper_thread
|
|
484
487
|
end
|
|
485
488
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
489
|
+
if reaper && !reaper.equal?(Thread.current)
|
|
490
|
+
begin
|
|
491
|
+
reaper.join(REAPER_JOIN_TIMEOUT)
|
|
492
|
+
rescue StandardError
|
|
493
|
+
# The reaper is gone either way; the exception that ended it is not this method's to report.
|
|
494
|
+
end
|
|
491
495
|
|
|
492
|
-
|
|
496
|
+
reaper.kill if reaper.alive?
|
|
497
|
+
end
|
|
493
498
|
@reaper_thread = nil
|
|
494
499
|
|
|
495
500
|
teardown_listen_subscriptions
|
|
@@ -521,12 +526,19 @@ module MCP
|
|
|
521
526
|
# a per-request sink for its ephemeral session and flushes it as SSE frames ahead of the final response.
|
|
522
527
|
# Checked before the stateless guard, since modern requests are served in stateless deployments too.
|
|
523
528
|
# The sink is bounded; past `MAX_MODERN_REQUEST_NOTIFICATIONS` the notification is dropped as
|
|
524
|
-
# non-delivery (`false`), never handed to the legacy paths below.
|
|
529
|
+
# non-delivery (`false`), never handed to the legacy paths below. It holds each notification as JSON,
|
|
530
|
+
# generated here so that one that cannot be generated is dropped on its own instead of failing
|
|
531
|
+
# the whole response when the sink is flushed.
|
|
525
532
|
sink = @mutex.synchronize { session_id && @modern_request_sinks[session_id] }
|
|
526
533
|
if sink
|
|
527
534
|
return false if sink.size >= MAX_MODERN_REQUEST_NOTIFICATIONS
|
|
528
535
|
|
|
529
|
-
|
|
536
|
+
begin
|
|
537
|
+
sink << notification.to_json
|
|
538
|
+
rescue JSON::GeneratorError => e
|
|
539
|
+
MCP.configuration.exception_reporter.call(e, { session_id: session_id, error: "Failed to generate notification" })
|
|
540
|
+
return false
|
|
541
|
+
end
|
|
530
542
|
return true
|
|
531
543
|
end
|
|
532
544
|
|
|
@@ -931,16 +943,26 @@ module MCP
|
|
|
931
943
|
# `nil` covers notifications and cancellation-suppressed responses; ack with 202 like the legacy notification path.
|
|
932
944
|
return handle_accepted if response.nil?
|
|
933
945
|
|
|
946
|
+
# A response that cannot be generated answers its request with Internal error, as `JsonRpcHandler.handle_json`
|
|
947
|
+
# does on the legacy paths, instead of failing the whole exchange with a 500.
|
|
948
|
+
response_json = begin
|
|
949
|
+
response.to_json
|
|
950
|
+
rescue JSON::GeneratorError => e
|
|
951
|
+
MCP.configuration.exception_reporter.call(e, { error: "Failed to generate response" })
|
|
952
|
+
response = JsonRpcHandler.encodable_response(response)
|
|
953
|
+
response.to_json
|
|
954
|
+
end
|
|
955
|
+
|
|
934
956
|
# Notifications a handler emitted during the request ride the request's own response stream as SSE frames ahead of
|
|
935
957
|
# the final response (SEP-2575); a request that emitted none keeps the single JSON exchange and its HTTP status ladder.
|
|
936
958
|
# Delivery is buffered: frames flush after the handler returns, preserving order but not real-time interleaving
|
|
937
959
|
# (the TypeScript and Python SDKs stream live), and the sink grows with the handler's notification count.
|
|
938
960
|
# Live streaming can follow without changing the wire shape.
|
|
939
961
|
if notifications.empty?
|
|
940
|
-
[modern_http_status(response), { "content-type" => "application/json" }, [
|
|
962
|
+
[modern_http_status(response), { "content-type" => "application/json" }, [response_json]]
|
|
941
963
|
else
|
|
942
|
-
frames = notifications + [
|
|
943
|
-
sse_body = frames.map { |frame| "event: message\ndata: #{frame
|
|
964
|
+
frames = notifications + [response_json]
|
|
965
|
+
sse_body = frames.map { |frame| "event: message\ndata: #{frame}\n\n" }.join
|
|
944
966
|
[200, SSE_HEADERS.dup, [sse_body]]
|
|
945
967
|
end
|
|
946
968
|
rescue StandardError => e
|
|
@@ -958,7 +980,7 @@ module MCP
|
|
|
958
980
|
# converge on enforcement.
|
|
959
981
|
def validate_modern_headers(request, body, header_version)
|
|
960
982
|
params = body[:params]
|
|
961
|
-
meta_version = params.is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
|
|
983
|
+
meta_version = params.is_a?(Hash) && params[:_meta].is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
|
|
962
984
|
if meta_version && meta_version != header_version
|
|
963
985
|
return header_mismatch_response(
|
|
964
986
|
"MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
|
|
@@ -1034,7 +1056,7 @@ module MCP
|
|
|
1034
1056
|
|
|
1035
1057
|
begin
|
|
1036
1058
|
if RequestEnvelope.modern?(params)
|
|
1037
|
-
RequestEnvelope.parse!(params, request: params)
|
|
1059
|
+
envelope = RequestEnvelope.parse!(params, request: params)
|
|
1038
1060
|
else
|
|
1039
1061
|
return invalid_request_response("Invalid Request: modern requests require the SEP-2575 `_meta` envelope")
|
|
1040
1062
|
end
|
|
@@ -1074,7 +1096,7 @@ module MCP
|
|
|
1074
1096
|
return too_many_listen_subscriptions_response(request_id)
|
|
1075
1097
|
end
|
|
1076
1098
|
|
|
1077
|
-
honored = honored_filter(filter)
|
|
1099
|
+
honored = honored_filter(filter, meta: params[:_meta], envelope: envelope, auth_info: auth_info)
|
|
1078
1100
|
|
|
1079
1101
|
# The acknowledgement echoes the honored filter, whose resource URIs are client-supplied strings,
|
|
1080
1102
|
# so it is encoded before the stream is committed to: a URI that cannot be written as JSON is
|
|
@@ -1321,8 +1343,9 @@ module MCP
|
|
|
1321
1343
|
|
|
1322
1344
|
# Per SEP-2575, the server MUST NOT send notification types the client has not requested,
|
|
1323
1345
|
# and the acknowledgement only includes types the server actually supports
|
|
1324
|
-
# (derived from its declared capabilities).
|
|
1325
|
-
|
|
1346
|
+
# (derived from its declared capabilities). Resource URIs are further narrowed to those
|
|
1347
|
+
# the server's `resources_subscribe_handler` accepts; the field is omitted when none remain.
|
|
1348
|
+
def honored_filter(filter, meta: nil, envelope: nil, auth_info: nil)
|
|
1326
1349
|
capabilities = @server.capabilities
|
|
1327
1350
|
honored = {}
|
|
1328
1351
|
honored[:toolsListChanged] = true if filter[:toolsListChanged] && capability_flag?(capabilities, :tools, :listChanged)
|
|
@@ -1331,7 +1354,8 @@ module MCP
|
|
|
1331
1354
|
|
|
1332
1355
|
subscriptions = filter[:resourceSubscriptions]
|
|
1333
1356
|
if capability_flag?(capabilities, :resources, :subscribe) && subscriptions.is_a?(Array) && !subscriptions.empty?
|
|
1334
|
-
|
|
1357
|
+
uris = @server.subscribable_resource_uris(subscriptions, meta: meta, envelope: envelope, auth_info: auth_info)
|
|
1358
|
+
honored[:resourceSubscriptions] = uris unless uris.empty?
|
|
1335
1359
|
end
|
|
1336
1360
|
|
|
1337
1361
|
honored
|
|
@@ -1875,7 +1899,7 @@ module MCP
|
|
|
1875
1899
|
end
|
|
1876
1900
|
|
|
1877
1901
|
def validate_accept_header(request, required_types)
|
|
1878
|
-
accept_header = request
|
|
1902
|
+
accept_header = header_bytes(request, "HTTP_ACCEPT")
|
|
1879
1903
|
return not_acceptable_response(required_types) unless accept_header
|
|
1880
1904
|
|
|
1881
1905
|
accepted_types = parse_accept_header(accept_header)
|
|
@@ -1894,7 +1918,7 @@ module MCP
|
|
|
1894
1918
|
end
|
|
1895
1919
|
|
|
1896
1920
|
def validate_content_type(request)
|
|
1897
|
-
content_type = request
|
|
1921
|
+
content_type = header_bytes(request, "CONTENT_TYPE")
|
|
1898
1922
|
media_type = content_type&.split(";")&.first&.strip&.downcase
|
|
1899
1923
|
return if media_type == "application/json"
|
|
1900
1924
|
|
|
@@ -2404,10 +2428,18 @@ module MCP
|
|
|
2404
2428
|
validate_host(request) || validate_origin(request)
|
|
2405
2429
|
end
|
|
2406
2430
|
|
|
2431
|
+
# A header value as bytes. Rack servers hand header values over as ASCII-8BIT, where every byte is valid, but a framework
|
|
2432
|
+
# or a test request can tag them UTF-8, and then `downcase`, `split`, and regexps raise on an invalid byte sequence
|
|
2433
|
+
# before any rescue applies. Reading the value as bytes puts both on the same footing: a malformed value fails its comparison
|
|
2434
|
+
# and gets the rejection that comparison produces, instead of failing the request.
|
|
2435
|
+
def header_bytes(request, name)
|
|
2436
|
+
request.env[name]&.b
|
|
2437
|
+
end
|
|
2438
|
+
|
|
2407
2439
|
# Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
|
|
2408
2440
|
# A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
|
|
2409
2441
|
def validate_host(request)
|
|
2410
|
-
host = request
|
|
2442
|
+
host = header_bytes(request, "HTTP_HOST")
|
|
2411
2443
|
return if host.nil?
|
|
2412
2444
|
|
|
2413
2445
|
# An `allowed_hosts:` entry matches either the bare host name (any port)
|
|
@@ -2422,7 +2454,7 @@ module MCP
|
|
|
2422
2454
|
# A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
|
|
2423
2455
|
# rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
|
|
2424
2456
|
def validate_origin(request)
|
|
2425
|
-
origin = request
|
|
2457
|
+
origin = header_bytes(request, "HTTP_ORIGIN")
|
|
2426
2458
|
return if origin.nil?
|
|
2427
2459
|
return if same_origin?(origin, request)
|
|
2428
2460
|
return if @allowed_origins.include?(origin.downcase)
|
|
@@ -2443,7 +2475,7 @@ module MCP
|
|
|
2443
2475
|
# but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
|
|
2444
2476
|
# both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
|
|
2445
2477
|
def same_origin?(origin, request)
|
|
2446
|
-
host = request
|
|
2478
|
+
host = header_bytes(request, "HTTP_HOST")
|
|
2447
2479
|
return false if host.nil?
|
|
2448
2480
|
|
|
2449
2481
|
normalized = origin.downcase
|