mcp 1.8.1 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +2 -0
- data/lib/json_rpc_handler.rb +32 -2
- data/lib/mcp/client/skill_errors.rb +45 -0
- data/lib/mcp/client/skills.rb +430 -0
- data/lib/mcp/client/stdio.rb +12 -2
- data/lib/mcp/client/tool.rb +8 -2
- data/lib/mcp/client.rb +35 -2
- data/lib/mcp/methods.rb +5 -0
- data/lib/mcp/server/transports/streamable_http_transport.rb +54 -22
- data/lib/mcp/server.rb +403 -17
- data/lib/mcp/server_session.rb +4 -4
- data/lib/mcp/skill/directory.rb +316 -0
- data/lib/mcp/skill/frontmatter.rb +262 -0
- data/lib/mcp/skill/resource_entry.rb +109 -0
- data/lib/mcp/skill.rb +522 -0
- data/lib/mcp/tool/output_schema.rb +4 -3
- data/lib/mcp/version.rb +1 -1
- data/lib/mcp.rb +1 -0
- metadata +8 -2
data/lib/mcp/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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1349
|
+
advertised = effective_capabilities
|
|
1350
|
+
return advertised if @transport.respond_to?(:serves_subscriptions_listen?) && @transport.serves_subscriptions_listen?
|
|
1227
1351
|
|
|
1228
|
-
without_notification_flags(
|
|
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
|
-
|
|
1359
|
+
advertised = effective_capabilities
|
|
1360
|
+
return advertised unless @transport.respond_to?(:delivers_notifications?) && !@transport.delivers_notifications?
|
|
1236
1361
|
|
|
1237
|
-
without_notification_flags(
|
|
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
|
data/lib/mcp/server_session.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
323
|
+
@server.configuration.exception_reporter.call(e, { notification: "log_message" })
|
|
324
324
|
end
|
|
325
325
|
|
|
326
326
|
private
|