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.
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
 
@@ -303,7 +328,8 @@ module MCP
303
328
  # for the current request, as in `handle`.
304
329
  # @return [String, nil] The JSON-RPC response as JSON, or `nil` for notifications.
305
330
  def handle_json(request, session: nil, auth_info: nil)
306
- JsonRpcHandler.handle_json(request, id_validation_pattern: nil) do |method, request_id|
331
+ on_generate_error = ->(error) { report_exception(error, { error: "Failed to generate response" }) }
332
+ JsonRpcHandler.handle_json(request, id_validation_pattern: nil, on_generate_error: on_generate_error) do |method, request_id|
307
333
  handle_request(request, method, session: session, related_request_id: request_id, auth_info: auth_info)
308
334
  end
309
335
  end
@@ -331,6 +357,7 @@ module MCP
331
357
  mime_type: mime_type, annotations: annotations, size: size, meta: meta,
332
358
  &block
333
359
  )
360
+ ensure_no_skill_file_collision!([resource.uri])
334
361
  @resources << resource
335
362
  @resource_index[resource.uri] = resource
336
363
 
@@ -349,6 +376,7 @@ module MCP
349
376
  end
350
377
 
351
378
  def resources=(resources)
379
+ ensure_no_skill_file_collision!(resources.map(&:uri))
352
380
  @resources = resources
353
381
  @resource_index = index_resources_by_uri(resources)
354
382
  end
@@ -467,6 +495,48 @@ module MCP
467
495
  @handlers[Methods::RESOURCES_READ] = block
468
496
  end
469
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
+
470
540
  # Sets a custom handler for `completion/complete` requests.
471
541
  # The block receives the parsed request params and should return completion values.
472
542
  #
@@ -500,6 +570,36 @@ module MCP
500
570
  @handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
501
571
  end
502
572
 
573
+ # Returns the distinct members of `uris` that the `resources_subscribe_handler` accepts, calling it once per URI
574
+ # with `{ uri: }`, plus `_meta` when given. `subscriptions/listen` takes the place of `resources/subscribe`
575
+ # in the 2026-07-28 lifecycle, so the handler keeps deciding which resources a client may follow there too.
576
+ # A URI the handler raises for is reported and left out, as the spec lets the acknowledgement honor a subset of
577
+ # what was requested; any return value accepts the URI and is otherwise discarded. The `server_context` has no session,
578
+ # related request id, or cancellation. Public only so that `StreamableHTTPTransport` can call it.
579
+ #
580
+ # @api private
581
+ # @param uris [Array<String>] The `resourceSubscriptions` of a `subscriptions/listen` request.
582
+ # @param meta [Hash, nil] The `_meta` of that request.
583
+ # @param envelope [MCP::RequestEnvelope, nil] The envelope of that request.
584
+ # @param auth_info [MCP::Server::OAuth::AccessToken, nil] The token that authenticated that request.
585
+ # @return [Array<String>]
586
+ def subscribable_resource_uris(uris, meta: nil, envelope: nil, auth_info: nil)
587
+ handler = @handlers[Methods::RESOURCES_SUBSCRIBE]
588
+
589
+ uris.uniq.select do |uri|
590
+ params = { uri: uri }
591
+ params[:_meta] = meta if meta
592
+ dispatch_optional_context_handler(handler, params, envelope: envelope, auth_info: auth_info)
593
+ true
594
+ rescue RequestHandlerError => e
595
+ report_exception(e.original_error || e, { request: params })
596
+ false
597
+ rescue => e
598
+ report_exception(e, { request: params })
599
+ false
600
+ end
601
+ end
602
+
503
603
  def build_sampling_params(
504
604
  capabilities,
505
605
  messages:,
@@ -543,6 +643,7 @@ module MCP
543
643
 
544
644
  def validate!
545
645
  validate_tool_name!
646
+ validate_skills_capabilities!(@capabilities)
546
647
 
547
648
  # NOTE: The draft protocol version is the next version after 2025-11-25.
548
649
  if @configuration.protocol_version <= "2025-06-18"
@@ -652,7 +753,8 @@ module MCP
652
753
  end
653
754
 
654
755
  begin
655
- 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)
656
758
  rescue Methods::MissingRequiredCapabilityError => e
657
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,
658
760
  # which the TypeScript and Python servers return for a handler they never registered, and which the Streamable HTTP transport maps to
@@ -697,6 +799,7 @@ module MCP
697
799
  server_context: { request: request },
698
800
  exception_already_reported: ->(e) { reported_exception.equal?(e) },
699
801
  ) do
802
+ refuse_non_object_meta!(params)
700
803
  envelope = lift_request_envelope(params, method: method, session: session)
701
804
 
702
805
  # The envelope's `logLevel` member replaces `logging/setLevel` in the modern lifecycle:
@@ -829,6 +932,26 @@ module MCP
829
932
  RequestEnvelope.modern?(request.is_a?(Hash) ? request[:params] : nil)
830
933
  end
831
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
+
832
955
  # Lifts the SEP-2575 per-request `_meta` envelope for modern requests. Only a request whose `_meta` carries
833
956
  # the full required triple is classified as modern; a partial triple keeps flowing through the legacy path untouched.
834
957
  # Notifications carry no envelope (their `_meta` is a `NotificationMetaObject`), and `server/discover` is
@@ -1223,18 +1346,20 @@ module MCP
1223
1346
  # promise delivery over `subscriptions/listen` streams, so they are stripped when the transport does not serve that RPC
1224
1347
  # (e.g. stdio), matching the Python SDK's era-aware capability derivation.
1225
1348
  def discover_capabilities
1226
- 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?
1227
1351
 
1228
- without_notification_flags(capabilities)
1352
+ without_notification_flags(advertised)
1229
1353
  end
1230
1354
 
1231
1355
  # Capabilities as advertised by `initialize`. A transport that delivers no notifications to its sessions
1232
1356
  # (stateless Streamable HTTP) cannot keep the promise the `listChanged` and `subscribe` flags make, so they are
1233
1357
  # left out, as `server/discover` leaves them out when `subscriptions/listen` is not served.
1234
1358
  def handshake_capabilities
1235
- 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?
1236
1361
 
1237
- without_notification_flags(capabilities)
1362
+ without_notification_flags(advertised)
1238
1363
  end
1239
1364
 
1240
1365
  def without_notification_flags(capabilities)
@@ -1299,7 +1424,7 @@ module MCP
1299
1424
  end
1300
1425
  end
1301
1426
 
1302
- progress_token = request.dig(:_meta, :progressToken)
1427
+ progress_token = request.dig(:_meta, :progressToken) if request[:_meta].is_a?(Hash)
1303
1428
 
1304
1429
  response = call_tool_with_args(
1305
1430
  tool,
@@ -1386,7 +1511,7 @@ module MCP
1386
1511
  resources = if @resources_list_handler
1387
1512
  invoke_resources_list_handler(request, server_context)
1388
1513
  else
1389
- @resources
1514
+ @resources + skill_manifest_resources
1390
1515
  end
1391
1516
 
1392
1517
  page = paginate(resources, cursor: cursor_from(request), page_size: @page_size, request: request, &:to_h)
@@ -1394,6 +1519,17 @@ module MCP
1394
1519
  apply_cache_metadata({ resources: page[:items], nextCursor: page[:next_cursor] }.compact)
1395
1520
  end
1396
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
+
1397
1533
  # Calls the `resources_list_handler` block, forwarding `server_context:` only when the block opts in
1398
1534
  # by declaring the keyword (the same rule `dispatch_optional_context_handler` applies).
1399
1535
  def invoke_resources_list_handler(request, server_context)
@@ -1404,6 +1540,229 @@ module MCP
1404
1540
  end
1405
1541
  end
1406
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
+
1407
1766
  # Default `resources/read` handler: routes to class-based resources and resource templates.
1408
1767
  # Fully replaced when `resources_read_handler` is set. When no class-based resource or template is registered,
1409
1768
  # unknown URIs keep the historical no-op `[]` response instead of raising.
@@ -1411,11 +1770,28 @@ module MCP
1411
1770
  uri = request[:uri]
1412
1771
  add_instrumentation_data(resource_uri: uri)
1413
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
+
1414
1778
  resource = @resource_index[uri]
1415
1779
  if resource.is_a?(Class)
1416
1780
  return normalize_resource_contents(call_resource_contents(resource, server_context))
1417
1781
  end
1418
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
+
1419
1795
  @resource_templates.each do |template|
1420
1796
  next unless template.is_a?(Class)
1421
1797
 
@@ -1560,7 +1936,7 @@ module MCP
1560
1936
  # (progress, cancellation, nested server-to-client requests).
1561
1937
  def build_server_context(request:, session:, related_request_id:, cancellation:, envelope: nil, auth_info: nil)
1562
1938
  meta_source = request.is_a?(Hash) ? request : {}
1563
- progress_token = meta_source.dig(:_meta, :progressToken)
1939
+ progress_token = meta_source.dig(:_meta, :progressToken) if meta_source[:_meta].is_a?(Hash)
1564
1940
  progress = Progress.new(notification_target: session, progress_token: progress_token, related_request_id: related_request_id)
1565
1941
  retry_fields = mrtr_retry_fields(meta_source)
1566
1942
  ServerContext.new(
@@ -1734,5 +2110,15 @@ module MCP
1734
2110
 
1735
2111
  { completion: { values: values, total: total, hasMore: has_more }.compact }
1736
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
1737
2123
  end
1738
2124
  end
@@ -275,14 +275,14 @@ module MCP
275
275
  def notify_elicitation_complete(elicitation_id:)
276
276
  send_to_transport(Methods::NOTIFICATIONS_ELICITATION_COMPLETE, { elicitationId: elicitation_id })
277
277
  rescue => e
278
- @server.report_exception(e, notification: "elicitation_complete")
278
+ @server.configuration.exception_reporter.call(e, { notification: "elicitation_complete" })
279
279
  end
280
280
 
281
281
  # Sends a resource updated notification to this session only.
282
282
  def notify_resources_updated(uri:)
283
283
  send_to_transport(Methods::NOTIFICATIONS_RESOURCES_UPDATED, { "uri" => uri })
284
284
  rescue => e
285
- @server.report_exception(e, notification: "resources_updated")
285
+ @server.configuration.exception_reporter.call(e, { notification: "resources_updated" })
286
286
  end
287
287
 
288
288
  # Sends a progress notification to this session only.
@@ -296,7 +296,7 @@ module MCP
296
296
 
297
297
  send_to_transport(Methods::NOTIFICATIONS_PROGRESS, params, related_request_id: related_request_id)
298
298
  rescue => e
299
- @server.report_exception(e, notification: "progress")
299
+ @server.configuration.exception_reporter.call(e, { notification: "progress" })
300
300
  end
301
301
 
302
302
  # Sends a log message notification to this session only.
@@ -320,7 +320,7 @@ module MCP
320
320
 
321
321
  send_to_transport(Methods::NOTIFICATIONS_MESSAGE, params, related_request_id: related_request_id)
322
322
  rescue => e
323
- @server.report_exception(e, { notification: "log_message" })
323
+ @server.configuration.exception_reporter.call(e, { notification: "log_message" })
324
324
  end
325
325
 
326
326
  private