Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 25 additions & 6 deletions lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -407,19 +407,25 @@ def completion_handler(&block)
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
Expand Down Expand Up @@ -621,8 +627,9 @@ def handle_request(request, method, session: nil, related_request_id: nil)
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
Expand Down Expand Up @@ -906,6 +913,18 @@ def validate_resource_subscription_params!(params)
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)
Expand Down
74 changes: 74 additions & 0 deletions test/mcp/server_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3623,6 +3623,80 @@ def read_resource_request(uri)
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",
Expand Down