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.
@@ -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
- begin
487
- reaper&.join(REAPER_JOIN_TIMEOUT)
488
- rescue StandardError
489
- # The reaper is gone either way; the exception that ended it is not this method's to report.
490
- end
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
- reaper.kill if reaper&.alive?
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.env["HTTP_ACCEPT"]
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.env["CONTENT_TYPE"]
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.env["HTTP_HOST"]
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.env["HTTP_ORIGIN"]
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.env["HTTP_HOST"]
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, :capabilities, :transport, :logging_message_notification
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
- Methods.ensure_capability!(method, capabilities)
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
- return capabilities if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
1349
+ advertised = effective_capabilities
1350
+ return advertised if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
1258
1351
 
1259
- without_notification_flags(capabilities)
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
- return capabilities unless @transport.respond_to?(:delivers_notifications?) && !@transport.delivers_notifications?
1359
+ advertised = effective_capabilities
1360
+ return advertised unless @transport.respond_to?(:delivers_notifications?) && !@transport.delivers_notifications?
1267
1361
 
1268
- without_notification_flags(capabilities)
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