Pass a handler-returned _meta through the subscribe result
## Motivation and Context
`resources/subscribe` and `resources/unsubscribe` answered with a hardcoded `{}`, discarding whatever
the registered handler returned. A server had no way to attach anything to the response - not even `_meta`,
which the specification allows on every result. The handler docstring justified the discard as "always
an empty result `{}` per the MCP specification", but the spec does not require an empty object here:
the subscribe result is an `EmptyResult`, and every result may carry `_meta`.
The one field that is interoperable is `_meta`. The TypeScript SDK validates the subscribe result against
`EmptyResultSchema.strict()`, which rejects any member other than `_meta`, so a top-level field like
a bespoke `subscriptionId` would be refused by a TypeScript client. The Python SDK passes the handler's `EmptyResult`
return through, `_meta` included. Passing `_meta` matches both while staying within what the spec defines.
The subscribe and unsubscribe handlers now pass a returned `_meta` hash through to the result and drop anything else.
`subscription_result` reads `_meta` under a symbol or string key and includes it only when it is itself
a hash; every other return shape - a non-hash, or a hash without a usable `_meta` - keeps the empty `{}` result it
produced before. Advisory data such as a subscription identifier goes under `_meta`, namespaced, rather than
at the top level. This affects only 2025-11-25 and earlier connections, since `resources/subscribe`
and `resources/unsubscribe` are removed from the modern lifecycle.
Fixes #508.
## How Has This Been Tested?
New tests in `test/mcp/server_test.rb` cover a `_meta` hash passing through on both `resources/subscribe`
and `resources/unsubscribe`, a non-`_meta` field being dropped, a string `_meta` key working, a non-hash `_meta`
being ignored, and a non-hash return keeping the empty result. The existing subscribe and unsubscribe tests,
which return `{}` and assert an empty result, are unchanged.
## Breaking Changes
None. A handler that does not return a hash carrying a `_meta` hash - which is every handler written against
the previous "return value is ignored" contract - still produces the empty `{}` result.
This commit is contained in:
parent
b195a21410
commit
8c2e89afd5
3 changed files with 111 additions and 6 deletions
12
README.md
12
README.md
|
|
@ -1493,6 +1493,18 @@ server.define_tool(name: "update_resource") do |server_context:, **args|
|
|||
end
|
||||
```
|
||||
|
||||
The `resources/subscribe` and `resources/unsubscribe` responses are empty results. The one field the spec allows
|
||||
alongside is `_meta`, so a handler that returns `{ _meta: { ... } }` has it passed through; any other field it
|
||||
returns is dropped. To convey a subscription identifier or other advisory data to the client, nest it under `_meta`
|
||||
rather than returning it at the top level, which interoperating clients reject:
|
||||
|
||||
```ruby
|
||||
server.resources_subscribe_handler do |params|
|
||||
id = subscriptions.create(params[:uri].to_s)
|
||||
{ _meta: { "myapp.example/subscriptionId" => id } }
|
||||
end
|
||||
```
|
||||
|
||||
### Sampling
|
||||
|
||||
The Model Context Protocol allows servers to request LLM completions from clients through the `sampling/createMessage` method.
|
||||
|
|
|
|||
|
|
@ -407,19 +407,25 @@ module MCP
|
|||
end
|
||||
|
||||
# Sets a custom handler for `resources/subscribe` requests.
|
||||
# The block receives the parsed request params. The return value is
|
||||
# ignored; the response is always an empty result `{}` per the MCP specification.
|
||||
# The block receives the parsed request params. The response is an empty result, except that
|
||||
# a `_meta` hash the block returns is passed through - the spec defines no other member for this result,
|
||||
# so any other field the block returns is dropped. Nest a subscription identifier or other advisory data
|
||||
# under `_meta`.
|
||||
#
|
||||
# @yield [params] The request params containing `:uri`.
|
||||
# @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
|
||||
def resources_subscribe_handler(&block)
|
||||
@handlers[Methods::RESOURCES_SUBSCRIBE] = block
|
||||
end
|
||||
|
||||
# Sets a custom handler for `resources/unsubscribe` requests.
|
||||
# The block receives the parsed request params. The return value is
|
||||
# ignored; the response is always an empty result `{}` per the MCP specification.
|
||||
# The block receives the parsed request params. The response is an empty result, except that
|
||||
# a `_meta` hash the block returns is passed through - the spec defines no other member for this result,
|
||||
# so any other field the block returns is dropped. Nest a subscription identifier or other advisory data
|
||||
# under `_meta`.
|
||||
#
|
||||
# @yield [params] The request params containing `:uri`.
|
||||
# @yieldreturn [Hash, nil] Optionally `{ _meta: { ... } }`; any other shape yields an empty result.
|
||||
def resources_unsubscribe_handler(&block)
|
||||
@handlers[Methods::RESOURCES_UNSUBSCRIBE] = block
|
||||
end
|
||||
|
|
@ -621,8 +627,9 @@ module MCP
|
|||
contents.is_a?(InputRequiredResult) ? contents : build_read_resource_result(contents)
|
||||
when Methods::RESOURCES_SUBSCRIBE, Methods::RESOURCES_UNSUBSCRIBE
|
||||
validate_resource_subscription_params!(params)
|
||||
dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||
{}
|
||||
handler_result = dispatch_optional_context_handler(@handlers[method], params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||
|
||||
subscription_result(handler_result)
|
||||
when Methods::TOOLS_CALL
|
||||
call_tool(params, session: session, related_request_id: related_request_id, cancellation: cancellation, envelope: envelope)
|
||||
when Methods::PROMPTS_GET
|
||||
|
|
@ -906,6 +913,18 @@ module MCP
|
|||
end
|
||||
end
|
||||
|
||||
# The `resources/subscribe` and `resources/unsubscribe` result is an empty object except for the optional `_meta`
|
||||
# every result may carry: the TypeScript SDK validates it against `EmptyResultSchema.strict()`,
|
||||
# which rejects any other member, so only `_meta` is passed through from the handler. A handler that returns
|
||||
# anything else keeps the empty `{}` result it had before, so returning a subscription identifier or
|
||||
# other advisory data means nesting it under `_meta`.
|
||||
def subscription_result(handler_result)
|
||||
return {} unless handler_result.is_a?(Hash)
|
||||
|
||||
meta = handler_result[:_meta] || handler_result["_meta"]
|
||||
meta.is_a?(Hash) ? { _meta: meta } : {}
|
||||
end
|
||||
|
||||
def validate_initialize_params!(params)
|
||||
unless params.is_a?(Hash)
|
||||
raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
|
||||
|
|
|
|||
|
|
@ -3623,6 +3623,80 @@ module MCP
|
|||
assert_equal "Invalid params", response[:error][:message]
|
||||
end
|
||||
|
||||
# Builds an initialized server that advertises the `resources.subscribe` capability.
|
||||
def subscription_server
|
||||
server = Server.new(name: "test_server", capabilities: { resources: { subscribe: true } })
|
||||
server.handle({ jsonrpc: "2.0", method: "initialize", id: 1, params: initialize_params })
|
||||
server.handle({ jsonrpc: "2.0", method: "notifications/initialized" })
|
||||
server
|
||||
end
|
||||
|
||||
# Sends `method` (`resources/subscribe` or `resources/unsubscribe`) and returns the JSON-RPC result.
|
||||
def handle_subscription(server, method)
|
||||
server.handle({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: method,
|
||||
params: { uri: "https://example.com/resource" },
|
||||
})[:result]
|
||||
end
|
||||
|
||||
test "#handle resources/subscribe passes a handler-returned _meta through to the result" do
|
||||
server = subscription_server
|
||||
server.resources_subscribe_handler { |_params| { _meta: { "acme.example/subscriptionId" => "sub-1" } } }
|
||||
|
||||
result = handle_subscription(server, "resources/subscribe")
|
||||
|
||||
assert_equal({ _meta: { "acme.example/subscriptionId" => "sub-1" } }, result)
|
||||
end
|
||||
|
||||
test "#handle resources/unsubscribe passes a handler-returned _meta through to the result" do
|
||||
server = subscription_server
|
||||
server.resources_unsubscribe_handler { |_params| { _meta: { "acme.example/note" => "gone" } } }
|
||||
|
||||
result = handle_subscription(server, "resources/unsubscribe")
|
||||
|
||||
assert_equal({ _meta: { "acme.example/note" => "gone" } }, result)
|
||||
end
|
||||
|
||||
test "#handle resources/subscribe drops a handler-returned field that is not _meta" do
|
||||
# The spec's result defines no member other than `_meta`, so a top-level field the handler adds is not
|
||||
# a subscription protocol; it stays out of the response.
|
||||
server = subscription_server
|
||||
server.resources_subscribe_handler { |_params| { subscriptionId: "sub-1" } }
|
||||
|
||||
result = handle_subscription(server, "resources/subscribe")
|
||||
|
||||
assert_equal({}, result)
|
||||
end
|
||||
|
||||
test "#handle resources/subscribe accepts a string _meta key from the handler" do
|
||||
server = subscription_server
|
||||
server.resources_subscribe_handler { |_params| { "_meta" => { "k" => "v" } } }
|
||||
|
||||
result = handle_subscription(server, "resources/subscribe")
|
||||
|
||||
assert_equal({ _meta: { "k" => "v" } }, result)
|
||||
end
|
||||
|
||||
test "#handle resources/subscribe ignores a handler-returned _meta that is not a hash" do
|
||||
server = subscription_server
|
||||
server.resources_subscribe_handler { |_params| { _meta: "not-a-hash" } }
|
||||
|
||||
result = handle_subscription(server, "resources/subscribe")
|
||||
|
||||
assert_equal({}, result)
|
||||
end
|
||||
|
||||
test "#handle resources/subscribe keeps an empty result when the handler returns a non-hash" do
|
||||
server = subscription_server
|
||||
server.resources_subscribe_handler { |_params| nil }
|
||||
|
||||
result = handle_subscription(server, "resources/subscribe")
|
||||
|
||||
assert_equal({}, result)
|
||||
end
|
||||
|
||||
test "#handle resources/subscribe without uri does not invoke a custom handler" do
|
||||
server = Server.new(
|
||||
name: "test_server",
|
||||
|
|
|
|||
Loading…
Reference in a new issue