mcp 1.8.2 → 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/mcp/client/skill_errors.rb +45 -0
- data/lib/mcp/client/skills.rb +430 -0
- 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 +25 -12
- data/lib/mcp/server.rb +371 -16
- 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
|
@@ -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
|
|
@@ -975,7 +980,7 @@ module MCP
|
|
|
975
980
|
# converge on enforcement.
|
|
976
981
|
def validate_modern_headers(request, body, header_version)
|
|
977
982
|
params = body[:params]
|
|
978
|
-
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
|
|
979
984
|
if meta_version && meta_version != header_version
|
|
980
985
|
return header_mismatch_response(
|
|
981
986
|
"MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
|
|
@@ -1894,7 +1899,7 @@ module MCP
|
|
|
1894
1899
|
end
|
|
1895
1900
|
|
|
1896
1901
|
def validate_accept_header(request, required_types)
|
|
1897
|
-
accept_header = request
|
|
1902
|
+
accept_header = header_bytes(request, "HTTP_ACCEPT")
|
|
1898
1903
|
return not_acceptable_response(required_types) unless accept_header
|
|
1899
1904
|
|
|
1900
1905
|
accepted_types = parse_accept_header(accept_header)
|
|
@@ -1913,7 +1918,7 @@ module MCP
|
|
|
1913
1918
|
end
|
|
1914
1919
|
|
|
1915
1920
|
def validate_content_type(request)
|
|
1916
|
-
content_type = request
|
|
1921
|
+
content_type = header_bytes(request, "CONTENT_TYPE")
|
|
1917
1922
|
media_type = content_type&.split(";")&.first&.strip&.downcase
|
|
1918
1923
|
return if media_type == "application/json"
|
|
1919
1924
|
|
|
@@ -2423,10 +2428,18 @@ module MCP
|
|
|
2423
2428
|
validate_host(request) || validate_origin(request)
|
|
2424
2429
|
end
|
|
2425
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
|
+
|
|
2426
2439
|
# Rejects a rebound `Host` (e.g. `evil.example.com` re-pointed at 127.0.0.1).
|
|
2427
2440
|
# A request without a `Host` header (e.g. HTTP/1.0) is allowed; the rebinding vector this guards against always carries one.
|
|
2428
2441
|
def validate_host(request)
|
|
2429
|
-
host = request
|
|
2442
|
+
host = header_bytes(request, "HTTP_HOST")
|
|
2430
2443
|
return if host.nil?
|
|
2431
2444
|
|
|
2432
2445
|
# An `allowed_hosts:` entry matches either the bare host name (any port)
|
|
@@ -2441,7 +2454,7 @@ module MCP
|
|
|
2441
2454
|
# A request without an `Origin` header (typical for non-browser MCP clients) is allowed. A browser cross-origin request is
|
|
2442
2455
|
# rejected unless the origin is same-origin or explicitly allow-listed via `allowed_origins:`.
|
|
2443
2456
|
def validate_origin(request)
|
|
2444
|
-
origin = request
|
|
2457
|
+
origin = header_bytes(request, "HTTP_ORIGIN")
|
|
2445
2458
|
return if origin.nil?
|
|
2446
2459
|
return if same_origin?(origin, request)
|
|
2447
2460
|
return if @allowed_origins.include?(origin.downcase)
|
|
@@ -2462,7 +2475,7 @@ module MCP
|
|
|
2462
2475
|
# but the `Origin`'s scheme is used to drop a redundant default port (`:80` for http, `:443` for https) from
|
|
2463
2476
|
# both sides so `http://example.com` matches `Host: example.com:80`. Comparison is case-insensitive.
|
|
2464
2477
|
def same_origin?(origin, request)
|
|
2465
|
-
host = request
|
|
2478
|
+
host = header_bytes(request, "HTTP_HOST")
|
|
2466
2479
|
return false if host.nil?
|
|
2467
2480
|
|
|
2468
2481
|
normalized = origin.downcase
|
data/lib/mcp/server.rb
CHANGED
|
@@ -180,16 +180,31 @@ module MCP
|
|
|
180
180
|
Methods::RESOURCES_LIST,
|
|
181
181
|
Methods::RESOURCES_TEMPLATES_LIST,
|
|
182
182
|
Methods::RESOURCES_READ,
|
|
183
|
+
# Both Skills results extend `CacheableResult`, so both carry the hints at 2026-07-28 (SEP-2640).
|
|
184
|
+
Methods::SKILLS_LIST,
|
|
185
|
+
Methods::SKILLS_GET,
|
|
183
186
|
].freeze
|
|
184
187
|
|
|
185
|
-
attr_accessor :description, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :
|
|
186
|
-
attr_reader :icons, :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security
|
|
188
|
+
attr_accessor :description, :name, :title, :version, :website_url, :instructions, :tools, :prompts, :resource_templates, :server_context, :configuration, :transport, :logging_message_notification
|
|
189
|
+
attr_reader :icons, :resources, :page_size, :client_capabilities, :ttl_ms, :cache_scope, :request_state_security, :capabilities
|
|
190
|
+
# The skills registered with `skills:` as a frozen Array (an empty one when only `skills_handlers` configured
|
|
191
|
+
# the extension), or nil when the server serves no skills.
|
|
192
|
+
attr_reader :skills
|
|
187
193
|
|
|
188
194
|
# Replaces the icons advertised in `serverInfo`; a Hash is converted through `Icon.from`.
|
|
189
195
|
def icons=(value)
|
|
190
196
|
@icons = Icon.from_list(value)
|
|
191
197
|
end
|
|
192
198
|
|
|
199
|
+
# Replaces the capabilities (a Hash or an `MCP::Server::Capabilities` builder). The Skills extension rules
|
|
200
|
+
# are checked before the value is taken, as at construction: the extension may be declared only when skills
|
|
201
|
+
# are configured, `resources` must be declared alongside it, and `directoryRead` is not served.
|
|
202
|
+
def capabilities=(value)
|
|
203
|
+
candidate = normalize_capabilities(value)
|
|
204
|
+
validate_skills_capabilities!(candidate)
|
|
205
|
+
@capabilities = candidate
|
|
206
|
+
end
|
|
207
|
+
|
|
193
208
|
def initialize(
|
|
194
209
|
description: nil,
|
|
195
210
|
icons: [],
|
|
@@ -202,6 +217,7 @@ module MCP
|
|
|
202
217
|
prompts: [],
|
|
203
218
|
resources: [],
|
|
204
219
|
resource_templates: [],
|
|
220
|
+
skills: nil,
|
|
205
221
|
server_context: nil,
|
|
206
222
|
configuration: nil,
|
|
207
223
|
capabilities: nil,
|
|
@@ -224,6 +240,17 @@ module MCP
|
|
|
224
240
|
@prompts = prompts.to_h { |p| [p.name_value, p] }
|
|
225
241
|
@resources = resources
|
|
226
242
|
@resource_templates = resource_templates
|
|
243
|
+
# Skills (SEP-2640): `nil`, whether omitted or supplied, means the extension is not served; an empty Array
|
|
244
|
+
# serves an empty catalog. The file map of directory-backed skills is built once here and never changes.
|
|
245
|
+
@skills_configured = !skills.nil?
|
|
246
|
+
@skills = skills.nil? ? nil : normalize_skills!(skills)
|
|
247
|
+
@skills_index = (@skills || []).to_h { |skill| [skill.uri, skill] }
|
|
248
|
+
@skill_files = Skill.file_map(@skills || [])
|
|
249
|
+
@skill_file_roots = Skill.file_roots(@skills || [])
|
|
250
|
+
@skills_list_handler = nil
|
|
251
|
+
@skills_get_handler = nil
|
|
252
|
+
@skills_methods_registered = false
|
|
253
|
+
ensure_no_skill_file_collision!(resources.map(&:uri))
|
|
227
254
|
@resource_index = index_resources_by_uri(resources)
|
|
228
255
|
@resources_list_handler = nil
|
|
229
256
|
@server_context = server_context
|
|
@@ -240,15 +267,12 @@ module MCP
|
|
|
240
267
|
@configuration = MCP.configuration.merge(configuration)
|
|
241
268
|
@client = nil
|
|
242
269
|
@client_protocol_version = nil
|
|
270
|
+
# Accept either a plain Hash or an `MCP::Server::Capabilities` builder, normalized before `validate!`
|
|
271
|
+
# so the Skills extension rules see the final Hash.
|
|
272
|
+
@capabilities = normalize_capabilities(capabilities)
|
|
243
273
|
|
|
244
274
|
validate!
|
|
245
275
|
|
|
246
|
-
# Accept either a plain Hash or an `MCP::Server::Capabilities` builder.
|
|
247
|
-
@capabilities = if capabilities.is_a?(Capabilities)
|
|
248
|
-
capabilities.to_h
|
|
249
|
-
else
|
|
250
|
-
capabilities || default_capabilities
|
|
251
|
-
end
|
|
252
276
|
@client_capabilities = nil
|
|
253
277
|
@logging_message_notification = nil
|
|
254
278
|
|
|
@@ -274,6 +298,7 @@ module MCP
|
|
|
274
298
|
# The protocol's own request methods, as opposed to the notifications above and to custom methods
|
|
275
299
|
# registered later: these need a request id to be run.
|
|
276
300
|
@protocol_request_methods = @handlers.keys.reject { |name| Methods.notification?(name) }.freeze
|
|
301
|
+
register_skills_handlers! if @skills_configured
|
|
277
302
|
@transport = transport
|
|
278
303
|
end
|
|
279
304
|
|
|
@@ -332,6 +357,7 @@ module MCP
|
|
|
332
357
|
mime_type: mime_type, annotations: annotations, size: size, meta: meta,
|
|
333
358
|
&block
|
|
334
359
|
)
|
|
360
|
+
ensure_no_skill_file_collision!([resource.uri])
|
|
335
361
|
@resources << resource
|
|
336
362
|
@resource_index[resource.uri] = resource
|
|
337
363
|
|
|
@@ -350,6 +376,7 @@ module MCP
|
|
|
350
376
|
end
|
|
351
377
|
|
|
352
378
|
def resources=(resources)
|
|
379
|
+
ensure_no_skill_file_collision!(resources.map(&:uri))
|
|
353
380
|
@resources = resources
|
|
354
381
|
@resource_index = index_resources_by_uri(resources)
|
|
355
382
|
end
|
|
@@ -468,6 +495,48 @@ module MCP
|
|
|
468
495
|
@handlers[Methods::RESOURCES_READ] = block
|
|
469
496
|
end
|
|
470
497
|
|
|
498
|
+
# Serves the Skills extension (SEP-2640) from per-request handlers instead of the `skills:` registry, for
|
|
499
|
+
# catalogs that depend on the caller. Both handlers are required because a server declaring the extension
|
|
500
|
+
# MUST answer `skills/get` for every skill it serves, listed or not.
|
|
501
|
+
#
|
|
502
|
+
# `list` receives the request params (and `server_context:` when it declares the keyword) and returns
|
|
503
|
+
# the whole collection of `MCP::Skill` in a stable order; the server paginates it. `get` returns the `MCP::Skill`
|
|
504
|
+
# for `params[:uri]`, or nil for `-32602`. A result that is not what these contracts describe is answered
|
|
505
|
+
# with `-32603`.
|
|
506
|
+
#
|
|
507
|
+
# A server serves skills from the `skills:` registry or from handlers, never both: a registered skill,
|
|
508
|
+
# its `SKILL.md` entry in `resources/list`, and its files are served to every caller, which a catalog that
|
|
509
|
+
# hides a skill from some callers must not inherit, so handlers are refused while the registry holds skills.
|
|
510
|
+
# With handlers the application serves the files itself and applies the same rule there, for example from
|
|
511
|
+
# `resources_read_handler` with `MCP::Skill.read_file(skills_for(server_context), params[:uri])`.
|
|
512
|
+
# A skill nested in the directory of another is served through the enclosing skill as well, whose manifest
|
|
513
|
+
# lists its files, so hiding it from a caller means withholding the enclosing skill too.
|
|
514
|
+
# What the handlers answer is never stamped `cacheScope: "public"`: where the `cache_scope` of the server
|
|
515
|
+
# would make it so, the result says `"private"`.
|
|
516
|
+
# Every check runs before anything is assigned, so a failure leaves the previous configuration in place.
|
|
517
|
+
def skills_handlers(list:, get:)
|
|
518
|
+
unless list.respond_to?(:call) && get.respond_to?(:call)
|
|
519
|
+
raise ArgumentError, "skills_handlers requires callable list: and get: handlers."
|
|
520
|
+
end
|
|
521
|
+
if @skills && !@skills.empty?
|
|
522
|
+
raise ArgumentError, "skills_handlers cannot be combined with skills in the skills: registry, which are served " \
|
|
523
|
+
"to every caller, files included. Return the skills from the handlers and serve their files yourself."
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
validate_skills_capabilities!(@capabilities, configured: true)
|
|
527
|
+
unless @skills_methods_registered
|
|
528
|
+
[Methods::SKILLS_LIST, Methods::SKILLS_GET].each do |method_name|
|
|
529
|
+
raise MethodAlreadyDefinedError, method_name if @handlers.key?(method_name)
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
@skills_list_handler = list
|
|
534
|
+
@skills_get_handler = get
|
|
535
|
+
@skills ||= [].freeze
|
|
536
|
+
@skills_configured = true
|
|
537
|
+
register_skills_handlers!
|
|
538
|
+
end
|
|
539
|
+
|
|
471
540
|
# Sets a custom handler for `completion/complete` requests.
|
|
472
541
|
# The block receives the parsed request params and should return completion values.
|
|
473
542
|
#
|
|
@@ -574,6 +643,7 @@ module MCP
|
|
|
574
643
|
|
|
575
644
|
def validate!
|
|
576
645
|
validate_tool_name!
|
|
646
|
+
validate_skills_capabilities!(@capabilities)
|
|
577
647
|
|
|
578
648
|
# NOTE: The draft protocol version is the next version after 2025-11-25.
|
|
579
649
|
if @configuration.protocol_version <= "2025-06-18"
|
|
@@ -683,7 +753,8 @@ module MCP
|
|
|
683
753
|
end
|
|
684
754
|
|
|
685
755
|
begin
|
|
686
|
-
|
|
756
|
+
# The same snapshot `initialize` and `server/discover` advertise, so dispatch cannot disagree with it.
|
|
757
|
+
Methods.ensure_capability!(method, effective_capabilities)
|
|
687
758
|
rescue Methods::MissingRequiredCapabilityError => e
|
|
688
759
|
# A method of a capability the server does not declare is one the server does not implement, so it is answered as Method not found,
|
|
689
760
|
# which the TypeScript and Python servers return for a handler they never registered, and which the Streamable HTTP transport maps to
|
|
@@ -728,6 +799,7 @@ module MCP
|
|
|
728
799
|
server_context: { request: request },
|
|
729
800
|
exception_already_reported: ->(e) { reported_exception.equal?(e) },
|
|
730
801
|
) do
|
|
802
|
+
refuse_non_object_meta!(params)
|
|
731
803
|
envelope = lift_request_envelope(params, method: method, session: session)
|
|
732
804
|
|
|
733
805
|
# The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle:
|
|
@@ -860,6 +932,26 @@ module MCP
|
|
|
860
932
|
RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil)
|
|
861
933
|
end
|
|
862
934
|
|
|
935
|
+
# `_meta` is an object on every request (`MetaObject`); a String, Array, or number there carries no envelope,
|
|
936
|
+
# no progress token, and no trace context, and the TypeScript and Python servers refuse it as invalid params on
|
|
937
|
+
# both lifecycles. Refused before the envelope is read and before any handler can meet it in `server_context`.
|
|
938
|
+
# `null` reads as absent, as it does for the Python server.
|
|
939
|
+
def refuse_non_object_meta!(params)
|
|
940
|
+
return unless params.is_a?(Hash)
|
|
941
|
+
|
|
942
|
+
# Read as `RequestEnvelope` reads it, so the two cannot disagree on a Hash holding both key spellings.
|
|
943
|
+
meta = params[:_meta]
|
|
944
|
+
meta = params["_meta"] if meta.nil?
|
|
945
|
+
return if meta.nil? || meta.is_a?(Hash)
|
|
946
|
+
|
|
947
|
+
raise RequestHandlerError.new(
|
|
948
|
+
"Invalid params: _meta must be an object",
|
|
949
|
+
params,
|
|
950
|
+
error_type: :invalid_params,
|
|
951
|
+
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
952
|
+
)
|
|
953
|
+
end
|
|
954
|
+
|
|
863
955
|
# Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
|
|
864
956
|
# the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
|
|
865
957
|
# Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
|
|
@@ -1254,18 +1346,20 @@ module MCP
|
|
|
1254
1346
|
# promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC
|
|
1255
1347
|
# (e.g. stdio), matching the Python SDK's era-aware capability derivation.
|
|
1256
1348
|
def discover_capabilities
|
|
1257
|
-
|
|
1349
|
+
advertised = effective_capabilities
|
|
1350
|
+
return advertised if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
|
|
1258
1351
|
|
|
1259
|
-
without_notification_flags(
|
|
1352
|
+
without_notification_flags(advertised)
|
|
1260
1353
|
end
|
|
1261
1354
|
|
|
1262
1355
|
# Capabilities as advertised by `initialize`. A transport that delivers no notifications to its sessions
|
|
1263
1356
|
# (stateless Streamable HTTP) cannot keep the promise the `listChanged` and `subscribe` flags make, so they are
|
|
1264
1357
|
# left out, as `server/discover` leaves them out when `subscriptions/listen` is not served.
|
|
1265
1358
|
def handshake_capabilities
|
|
1266
|
-
|
|
1359
|
+
advertised = effective_capabilities
|
|
1360
|
+
return advertised unless @transport.respond_to?(:delivers_notifications?) && !@transport.delivers_notifications?
|
|
1267
1361
|
|
|
1268
|
-
without_notification_flags(
|
|
1362
|
+
without_notification_flags(advertised)
|
|
1269
1363
|
end
|
|
1270
1364
|
|
|
1271
1365
|
def without_notification_flags(capabilities)
|
|
@@ -1330,7 +1424,7 @@ module MCP
|
|
|
1330
1424
|
end
|
|
1331
1425
|
end
|
|
1332
1426
|
|
|
1333
|
-
progress_token = request.dig(:_meta, :progressToken)
|
|
1427
|
+
progress_token = request.dig(:_meta, :progressToken) if request[:_meta].is_a?(Hash)
|
|
1334
1428
|
|
|
1335
1429
|
response = call_tool_with_args(
|
|
1336
1430
|
tool,
|
|
@@ -1417,7 +1511,7 @@ module MCP
|
|
|
1417
1511
|
resources = if @resources_list_handler
|
|
1418
1512
|
invoke_resources_list_handler(request, server_context)
|
|
1419
1513
|
else
|
|
1420
|
-
@resources
|
|
1514
|
+
@resources + skill_manifest_resources
|
|
1421
1515
|
end
|
|
1422
1516
|
|
|
1423
1517
|
page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
@@ -1425,6 +1519,17 @@ module MCP
|
|
|
1425
1519
|
apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
|
|
1426
1520
|
end
|
|
1427
1521
|
|
|
1522
|
+
# The `SKILL.md` of every registered skill as a resource entry, with `name` and `description` taken from
|
|
1523
|
+
# the frontmatter and `text/markdown`, as the Skills extension asks of a `SKILL.md` resource; a resource
|
|
1524
|
+
# the application registered at that URI takes its place.
|
|
1525
|
+
def skill_manifest_resources
|
|
1526
|
+
return [] if @skills.nil? || @skills.empty?
|
|
1527
|
+
|
|
1528
|
+
@skills.reject { |skill| @resource_index.key?(skill.uri) }.map do |skill|
|
|
1529
|
+
Resource.new(uri: skill.uri, name: skill.name, description: skill.frontmatter["description"], mime_type: "text/markdown")
|
|
1530
|
+
end
|
|
1531
|
+
end
|
|
1532
|
+
|
|
1428
1533
|
# Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in
|
|
1429
1534
|
# by declaring the keyword (the same rule `dispatch_optional_context_handler` applies).
|
|
1430
1535
|
def invoke_resources_list_handler(request, server_context)
|
|
@@ -1435,6 +1540,229 @@ module MCP
|
|
|
1435
1540
|
end
|
|
1436
1541
|
end
|
|
1437
1542
|
|
|
1543
|
+
# `skills/list` (SEP-2640): the registry, or the `list` handler's collection, paginated like `resources/list`.
|
|
1544
|
+
def list_skills(request, server_context: nil)
|
|
1545
|
+
skills = if @skills_list_handler
|
|
1546
|
+
result = invoke_skills_handler(@skills_list_handler, request, server_context)
|
|
1547
|
+
problem = skills_problem(result)
|
|
1548
|
+
raise invalid_skills_result(request, problem) if problem
|
|
1549
|
+
|
|
1550
|
+
result
|
|
1551
|
+
else
|
|
1552
|
+
@skills
|
|
1553
|
+
end
|
|
1554
|
+
|
|
1555
|
+
page = paginate(skills, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
|
|
1556
|
+
|
|
1557
|
+
apply_skills_cache_metadata({ skills: page[:items], nextCursor: page[:next_cursor] }.compact, @skills_list_handler)
|
|
1558
|
+
end
|
|
1559
|
+
|
|
1560
|
+
# `skills/get` (SEP-2640): answers for every skill the server serves, listed or not. The URI shape is checked
|
|
1561
|
+
# before any handler runs; a `get` handler's result must be the skill for exactly that URI.
|
|
1562
|
+
def get_skill(request, server_context: nil)
|
|
1563
|
+
# Absent, null, or Array params reach here through the generic JSON-RPC validation.
|
|
1564
|
+
uri = request.is_a?(Hash) ? request[:uri] : nil
|
|
1565
|
+
unless Skill::Uri.manifest_uri?(uri)
|
|
1566
|
+
raise RequestHandlerError.new(
|
|
1567
|
+
"Invalid params: uri is not a skill SKILL.md URI",
|
|
1568
|
+
request,
|
|
1569
|
+
error_type: :invalid_params,
|
|
1570
|
+
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
1571
|
+
error_data: refused_uri_data(uri),
|
|
1572
|
+
)
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1575
|
+
skill = if @skills_get_handler
|
|
1576
|
+
result = invoke_skills_handler(@skills_get_handler, request, server_context)
|
|
1577
|
+
unless result.nil?
|
|
1578
|
+
raise invalid_skills_result(request, "skills/get returned #{result.class}") unless result.is_a?(Skill)
|
|
1579
|
+
raise invalid_skills_result(request, "skills/get returned #{result.uri.dump} for #{uri.dump}") unless result.uri == uri
|
|
1580
|
+
|
|
1581
|
+
problem = skills_problem([result])
|
|
1582
|
+
raise invalid_skills_result(request, problem) if problem
|
|
1583
|
+
end
|
|
1584
|
+
result
|
|
1585
|
+
else
|
|
1586
|
+
@skills_index[uri]
|
|
1587
|
+
end
|
|
1588
|
+
|
|
1589
|
+
if skill.nil?
|
|
1590
|
+
raise RequestHandlerError.new(
|
|
1591
|
+
"Skill not found: #{uri.dump}",
|
|
1592
|
+
request,
|
|
1593
|
+
error_type: :invalid_params,
|
|
1594
|
+
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
1595
|
+
error_data: refused_uri_data(uri),
|
|
1596
|
+
)
|
|
1597
|
+
end
|
|
1598
|
+
|
|
1599
|
+
apply_skills_cache_metadata({ skill: skill.to_h }, @skills_get_handler)
|
|
1600
|
+
end
|
|
1601
|
+
|
|
1602
|
+
# A handler answers per caller and, returning skills, has no result of its own to carry a scope in,
|
|
1603
|
+
# so what it answered is never marked for shared caches, whatever scope the server was configured with.
|
|
1604
|
+
def apply_skills_cache_metadata(result, handler)
|
|
1605
|
+
stamped = apply_cache_metadata(result)
|
|
1606
|
+
return stamped unless handler && stamped[:cacheScope] == "public"
|
|
1607
|
+
|
|
1608
|
+
stamped.merge(cacheScope: "private")
|
|
1609
|
+
end
|
|
1610
|
+
|
|
1611
|
+
def invoke_skills_handler(handler, request, server_context)
|
|
1612
|
+
if handler_declares_server_context?(handler)
|
|
1613
|
+
handler.call(request, server_context: server_context)
|
|
1614
|
+
else
|
|
1615
|
+
handler.call(request)
|
|
1616
|
+
end
|
|
1617
|
+
end
|
|
1618
|
+
|
|
1619
|
+
# The wire sees one fixed message; what was wrong goes to the exception reporter only.
|
|
1620
|
+
def invalid_skills_result(request, problem)
|
|
1621
|
+
RequestHandlerError.new(
|
|
1622
|
+
"Skills handler returned an invalid result",
|
|
1623
|
+
request,
|
|
1624
|
+
error_type: :internal_error,
|
|
1625
|
+
error_code: JsonRpcHandler::ErrorCode::INTERNAL_ERROR,
|
|
1626
|
+
original_error: StandardError.new(problem),
|
|
1627
|
+
)
|
|
1628
|
+
end
|
|
1629
|
+
|
|
1630
|
+
# Describes the first problem in a set of skills served together, or returns nil: every element
|
|
1631
|
+
# an `MCP::Skill` and URIs unique. A skill may list any file under its own root, a nested skill's files
|
|
1632
|
+
# included, because the specification wants the enclosing manifest complete.
|
|
1633
|
+
def skills_problem(skills)
|
|
1634
|
+
unless skills.is_a?(Array) && skills.all? { |skill| skill.is_a?(Skill) }
|
|
1635
|
+
return "skills must be an Array of MCP::Skill (got #{skills.class})"
|
|
1636
|
+
end
|
|
1637
|
+
|
|
1638
|
+
registered = {}
|
|
1639
|
+
skills.each do |skill|
|
|
1640
|
+
return "skill #{skill.uri} is listed twice" if registered.key?(skill.uri)
|
|
1641
|
+
|
|
1642
|
+
registered[skill.uri] = true
|
|
1643
|
+
end
|
|
1644
|
+
|
|
1645
|
+
nil
|
|
1646
|
+
end
|
|
1647
|
+
|
|
1648
|
+
def normalize_skills!(skills)
|
|
1649
|
+
problem = skills_problem(skills)
|
|
1650
|
+
raise ArgumentError, "skills: #{problem}." if problem
|
|
1651
|
+
|
|
1652
|
+
skills.dup.freeze
|
|
1653
|
+
end
|
|
1654
|
+
|
|
1655
|
+
def register_skills_handlers!
|
|
1656
|
+
return if @skills_methods_registered
|
|
1657
|
+
|
|
1658
|
+
[Methods::SKILLS_LIST, Methods::SKILLS_GET].each do |method_name|
|
|
1659
|
+
raise MethodAlreadyDefinedError, method_name if @handlers.key?(method_name)
|
|
1660
|
+
end
|
|
1661
|
+
@handlers[Methods::SKILLS_LIST] = method(:list_skills)
|
|
1662
|
+
@handlers[Methods::SKILLS_GET] = method(:get_skill)
|
|
1663
|
+
# Request methods of the extension, so like the protocol's own they are not run without an id; added here
|
|
1664
|
+
# rather than at construction because `skills_handlers` registers them later.
|
|
1665
|
+
@protocol_request_methods = (@protocol_request_methods + [Methods::SKILLS_LIST, Methods::SKILLS_GET]).freeze
|
|
1666
|
+
@skills_methods_registered = true
|
|
1667
|
+
end
|
|
1668
|
+
|
|
1669
|
+
# A file of a directory-backed skill is served ahead of any resource with the same URI, so registering such
|
|
1670
|
+
# a resource would be silently shadowed; it is refused before anything is committed.
|
|
1671
|
+
def ensure_no_skill_file_collision!(uris)
|
|
1672
|
+
uris.each do |uri|
|
|
1673
|
+
next unless @skill_files.key?(uri)
|
|
1674
|
+
|
|
1675
|
+
raise ArgumentError, "Resource URI #{uri} is a file of a directory-backed skill and cannot be registered as a resource."
|
|
1676
|
+
end
|
|
1677
|
+
end
|
|
1678
|
+
|
|
1679
|
+
def normalize_capabilities(value)
|
|
1680
|
+
case value
|
|
1681
|
+
when Capabilities then value.to_h
|
|
1682
|
+
when nil then default_capabilities
|
|
1683
|
+
when Hash then value
|
|
1684
|
+
else raise ArgumentError, "capabilities must be a Hash or an MCP::Server::Capabilities (got #{value.class})."
|
|
1685
|
+
end
|
|
1686
|
+
end
|
|
1687
|
+
|
|
1688
|
+
# The capabilities a client sees in `initialize` and `server/discover`, and that dispatch gates on. The Skills
|
|
1689
|
+
# entry is computed from the configured state rather than read from the stored Hash, which the public reader
|
|
1690
|
+
# exposes for in-place mutation: when skills are configured the extension is advertised with `directoryRead`
|
|
1691
|
+
# removed (nothing serves it) and `resources` is ensured, when they are not it is absent. Only the nested
|
|
1692
|
+
# structures being modified are copied; the stored Hash is left as it is.
|
|
1693
|
+
def effective_capabilities
|
|
1694
|
+
base = @capabilities
|
|
1695
|
+
extensions = declared_extensions(base)
|
|
1696
|
+
declared_keys = skills_extension_keys(extensions)
|
|
1697
|
+
unless @skills_configured
|
|
1698
|
+
return base if declared_keys.empty?
|
|
1699
|
+
|
|
1700
|
+
stripped = extensions.reject { |key, _| declared_keys.include?(key) }
|
|
1701
|
+
snapshot = base.dup
|
|
1702
|
+
snapshot.delete(:extensions)
|
|
1703
|
+
snapshot.delete("extensions")
|
|
1704
|
+
snapshot[:extensions] = stripped unless stripped.empty?
|
|
1705
|
+
return snapshot
|
|
1706
|
+
end
|
|
1707
|
+
|
|
1708
|
+
settings = declared_keys.map { |key| extensions[key] }.find { |value| value.is_a?(Hash) } || {}
|
|
1709
|
+
settings = settings.reject { |key, _| key.to_s == "directoryRead" }
|
|
1710
|
+
others = extensions.reject { |key, _| declared_keys.include?(key) }
|
|
1711
|
+
snapshot = base.dup
|
|
1712
|
+
snapshot.delete("extensions")
|
|
1713
|
+
snapshot[:extensions] = others.merge(Skill::EXTENSION_ID => settings)
|
|
1714
|
+
# One `resources` key, so a second spelling cannot serialize after it and undo the declaration.
|
|
1715
|
+
resources = capability_member(snapshot, :resources)
|
|
1716
|
+
snapshot.delete("resources")
|
|
1717
|
+
snapshot[:resources] = resources.is_a?(Hash) ? resources : {}
|
|
1718
|
+
snapshot
|
|
1719
|
+
end
|
|
1720
|
+
|
|
1721
|
+
# The Skills extension rules for an explicitly supplied capabilities Hash (SEP-2640): the extension is declared
|
|
1722
|
+
# only when skills are configured, its settings are a Hash without `directoryRead`, and `resources` is declared
|
|
1723
|
+
# alongside it. Both key spellings are read, because JSON serialization erases the difference between
|
|
1724
|
+
# `:extensions` and `"extensions"` or between a Symbol and a String extension identifier.
|
|
1725
|
+
# Checked at construction, on `capabilities=`, and when handlers are installed.
|
|
1726
|
+
def validate_skills_capabilities!(caps, configured: @skills_configured)
|
|
1727
|
+
extensions = declared_extensions(caps)
|
|
1728
|
+
declared_keys = skills_extension_keys(extensions)
|
|
1729
|
+
|
|
1730
|
+
if !declared_keys.empty? && !configured
|
|
1731
|
+
raise ArgumentError, "capabilities declare #{Skill::EXTENSION_ID} but no skills are configured; " \
|
|
1732
|
+
"pass skills: (an empty Array serves an empty catalog) or call skills_handlers."
|
|
1733
|
+
end
|
|
1734
|
+
|
|
1735
|
+
declared_keys.each do |key|
|
|
1736
|
+
settings = extensions[key]
|
|
1737
|
+
unless settings.is_a?(Hash)
|
|
1738
|
+
raise ArgumentError, "The #{Skill::EXTENSION_ID} capability must be a Hash (got #{settings.class})."
|
|
1739
|
+
end
|
|
1740
|
+
if settings.any? { |name, value| name.to_s == "directoryRead" && value }
|
|
1741
|
+
raise ArgumentError, "directoryRead is not served by this release; do not declare it under #{Skill::EXTENSION_ID}."
|
|
1742
|
+
end
|
|
1743
|
+
end
|
|
1744
|
+
|
|
1745
|
+
if configured && !capability_member(caps, :resources).is_a?(Hash)
|
|
1746
|
+
raise ArgumentError, "SEP-2640 requires the resources capability alongside #{Skill::EXTENSION_ID}; declare resources too."
|
|
1747
|
+
end
|
|
1748
|
+
end
|
|
1749
|
+
|
|
1750
|
+
def capability_member(caps, name)
|
|
1751
|
+
value = caps[name]
|
|
1752
|
+
value.nil? ? caps[name.to_s] : value
|
|
1753
|
+
end
|
|
1754
|
+
|
|
1755
|
+
# The `extensions` declarations under both key spellings merged into one Hash (empty when there are none),
|
|
1756
|
+
# so a Skills declaration is found whichever spelling holds it and the other extensions survive the rewrite.
|
|
1757
|
+
def declared_extensions(caps)
|
|
1758
|
+
[caps[:extensions], caps["extensions"]].grep(Hash).reduce({}, :merge)
|
|
1759
|
+
end
|
|
1760
|
+
|
|
1761
|
+
# Every key of an `extensions` Hash that spells the Skills extension identifier, as a String or a Symbol.
|
|
1762
|
+
def skills_extension_keys(extensions)
|
|
1763
|
+
extensions.keys.select { |key| key.to_s == Skill::EXTENSION_ID }
|
|
1764
|
+
end
|
|
1765
|
+
|
|
1438
1766
|
# Default `resources/read` handler: routes to class-based resources and resource templates.
|
|
1439
1767
|
# Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
|
|
1440
1768
|
# unknown URIs keep the historical no-op `[]` response instead of raising.
|
|
@@ -1442,11 +1770,28 @@ module MCP
|
|
|
1442
1770
|
uri = request[:uri]
|
|
1443
1771
|
add_instrumentation_data(resource_uri: uri)
|
|
1444
1772
|
|
|
1773
|
+
# Files of directory-backed skills are looked up by exact string equality in a map built at construction,
|
|
1774
|
+
# so request data never reaches the filesystem; a miss falls through to the routing below.
|
|
1775
|
+
record = @skill_files[uri]
|
|
1776
|
+
return Skill.read_record(record, request) if record
|
|
1777
|
+
|
|
1445
1778
|
resource = @resource_index[uri]
|
|
1446
1779
|
if resource.is_a?(Class)
|
|
1447
1780
|
return normalize_resource_contents(call_resource_contents(resource, server_context))
|
|
1448
1781
|
end
|
|
1449
1782
|
|
|
1783
|
+
# A URI under a directory-backed skill that names none of its files: the manifest is complete, so the file
|
|
1784
|
+
# is not served, and the Skills extension asks for `-32602` where the base reader keeps its `[]`.
|
|
1785
|
+
if resource.nil? && uri.is_a?(String) && @skill_file_roots.any? { |root| Skill::Uri.under?(uri, root) }
|
|
1786
|
+
raise RequestHandlerError.new(
|
|
1787
|
+
"Skill file not found: #{uri.dump}",
|
|
1788
|
+
request,
|
|
1789
|
+
error_type: :invalid_params,
|
|
1790
|
+
error_code: JsonRpcHandler::ErrorCode::INVALID_PARAMS,
|
|
1791
|
+
error_data: refused_uri_data(uri),
|
|
1792
|
+
)
|
|
1793
|
+
end
|
|
1794
|
+
|
|
1450
1795
|
@resource_templates.each do |template|
|
|
1451
1796
|
next unless template.is_a?(Class)
|
|
1452
1797
|
|
|
@@ -1591,7 +1936,7 @@ module MCP
|
|
|
1591
1936
|
# (progress, cancellation, nested server-to-client requests).
|
|
1592
1937
|
def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil, auth_info: nil)
|
|
1593
1938
|
meta_source = request.is_a?(Hash) ? request : {}
|
|
1594
|
-
progress_token = meta_source.dig(:_meta, :progressToken)
|
|
1939
|
+
progress_token = meta_source.dig(:_meta, :progressToken) if meta_source[:_meta].is_a?(Hash)
|
|
1595
1940
|
progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
|
|
1596
1941
|
retry_fields = mrtr_retry_fields(meta_source)
|
|
1597
1942
|
ServerContext.new(
|
|
@@ -1765,5 +2110,15 @@ module MCP
|
|
|
1765
2110
|
|
|
1766
2111
|
{ completion: { values: values, total: total, hasMore: has_more }.compact }
|
|
1767
2112
|
end
|
|
2113
|
+
|
|
2114
|
+
# The error data of a Skills refusal: the request URI when it is a String, read as UTF-8 with its invalid bytes
|
|
2115
|
+
# replaced, so that the response can be generated whatever bytes the request held; nothing for another value,
|
|
2116
|
+
# which could hold such bytes anywhere, so the data falls back to the message. The messages quote the URI with
|
|
2117
|
+
# `dump` for the same reason, and so that the exception reporter gets one printable line.
|
|
2118
|
+
def refused_uri_data(uri)
|
|
2119
|
+
return unless uri.is_a?(String)
|
|
2120
|
+
|
|
2121
|
+
{ uri: uri.dup.force_encoding(Encoding::UTF_8).scrub }
|
|
2122
|
+
end
|
|
1768
2123
|
end
|
|
1769
2124
|
end
|