Support Capability Extensions per SEP-2133
## Motivation and Context SEP-2133 (modelcontextprotocol/modelcontextprotocol#2133, merged for the 2026-07-28 spec release) establishes the extensions framework for MCP: both `ClientCapabilities` and `ServerCapabilities` gain an optional `extensions` member keyed by reverse-DNS extension identifiers (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`), with arbitrary extension-defined configuration objects as values and an empty object meaning "supported with no settings". Official extensions such as MCP Apps (SEP-1865) and the Tasks extension (SEP-2663) are layered on this field. This mirrors the shape the TypeScript SDK merged (typescript-sdk#1630): plain declaration and passthrough, no registrar API and no key-format validation (the naming convention is documented instead). - `MCP::Server::Capabilities` gains `support_extensions` (repeated calls merge), accepts an `:extensions` key in its constructor hash, and includes `extensions` in `to_h`. The class was previously dead code: it was never required and `Server.new` only took plain hashes. It is now required by `lib/mcp/server.rb`, and `Server.new(capabilities:)` accepts either a plain Hash (as before) or a `Capabilities` instance. - Server-declared extensions appear in the `initialize` result; extensions the client declares during `initialize` were already stored verbatim and are readable via `Server#client_capabilities[:extensions]` and `ServerSession#client_capabilities[:extensions]` (now covered by tests). - `MCP::Client#connect(capabilities:)` already passes capabilities through verbatim; its documentation now covers the `extensions` member. Resolves #378. ## How Has This Been Tested? - New `test/mcp/server/capabilities_test.rb` covers the previously untested `Capabilities` class: empty default, constructor round-trip, extensions via constructor and `support_extensions`, merge semantics, nil tolerance, and omission from `to_h` when never declared. - New tests in `test/mcp/server_test.rb` assert that the `initialize` result carries declared extensions (from both a plain hash and a `Capabilities` instance) and that client-declared extensions are readable via `Server#client_capabilities` and `ServerSession#client_capabilities`. ## Breaking Changes None. `extensions` is additive and appears on the wire only when declared; servers constructed with plain capability hashes behave exactly as before.
This commit is contained in:
parent
3a163ad096
commit
2c62139154
6 changed files with 189 additions and 2 deletions
25
README.md
25
README.md
|
|
@ -231,6 +231,31 @@ server = MCP::Server.new(
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Capability Extensions
|
||||||
|
|
||||||
|
Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
|
||||||
|
Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
||||||
|
values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
|
||||||
|
|
||||||
|
On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
capabilities = MCP::Server::Capabilities.new
|
||||||
|
capabilities.support_tools
|
||||||
|
capabilities.support_extensions("com.example/feature" => { enabled: true })
|
||||||
|
|
||||||
|
server = MCP::Server.new(name: "my_server", capabilities: capabilities)
|
||||||
|
```
|
||||||
|
|
||||||
|
The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
|
||||||
|
readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
|
||||||
|
|
||||||
|
On the client, pass extensions through `connect`:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
|
||||||
|
```
|
||||||
|
|
||||||
### Server Context and Configuration Block Data
|
### Server Context and Configuration Block Data
|
||||||
|
|
||||||
#### `server_context`
|
#### `server_context`
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,9 @@ module MCP
|
||||||
#
|
#
|
||||||
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
|
||||||
# @param protocol_version [String, nil] Protocol version to offer.
|
# @param protocol_version [String, nil] Protocol version to offer.
|
||||||
# @param capabilities [Hash] Capabilities advertised by the client.
|
# @param capabilities [Hash] Capabilities advertised by the client. May include
|
||||||
|
# an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers,
|
||||||
|
# e.g. `{ extensions: { "com.example/feature" => {} } }`.
|
||||||
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
|
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
|
||||||
# does not expose an explicit handshake.
|
# does not expose an explicit handshake.
|
||||||
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ require_relative "methods"
|
||||||
require_relative "logging_message_notification"
|
require_relative "logging_message_notification"
|
||||||
require_relative "progress"
|
require_relative "progress"
|
||||||
require_relative "server_context"
|
require_relative "server_context"
|
||||||
|
require_relative "server/capabilities"
|
||||||
require_relative "server/pagination"
|
require_relative "server/pagination"
|
||||||
require_relative "server/transports"
|
require_relative "server/transports"
|
||||||
|
|
||||||
|
|
@ -142,7 +143,12 @@ module MCP
|
||||||
|
|
||||||
validate!
|
validate!
|
||||||
|
|
||||||
@capabilities = capabilities || default_capabilities
|
# Accept either a plain Hash or an `MCP::Server::Capabilities` builder.
|
||||||
|
@capabilities = if capabilities.is_a?(Capabilities)
|
||||||
|
capabilities.to_h
|
||||||
|
else
|
||||||
|
capabilities || default_capabilities
|
||||||
|
end
|
||||||
@client_capabilities = nil
|
@client_capabilities = nil
|
||||||
@logging_message_notification = nil
|
@logging_message_notification = nil
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ module MCP
|
||||||
def initialize(capabilities_hash = nil)
|
def initialize(capabilities_hash = nil)
|
||||||
@completions = nil
|
@completions = nil
|
||||||
@experimental = nil
|
@experimental = nil
|
||||||
|
@extensions = nil
|
||||||
@logging = nil
|
@logging = nil
|
||||||
@prompts = nil
|
@prompts = nil
|
||||||
@resources = nil
|
@resources = nil
|
||||||
|
|
@ -14,6 +15,7 @@ module MCP
|
||||||
if capabilities_hash
|
if capabilities_hash
|
||||||
support_completions if capabilities_hash.key?(:completions)
|
support_completions if capabilities_hash.key?(:completions)
|
||||||
support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
|
support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
|
||||||
|
support_extensions(capabilities_hash[:extensions]) if capabilities_hash.key?(:extensions)
|
||||||
support_logging if capabilities_hash.key?(:logging)
|
support_logging if capabilities_hash.key?(:logging)
|
||||||
|
|
||||||
if capabilities_hash.key?(:prompts)
|
if capabilities_hash.key?(:prompts)
|
||||||
|
|
@ -45,6 +47,17 @@ module MCP
|
||||||
@experimental = config || {}
|
@experimental = config || {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Declares support for capability extensions per SEP-2133. Keys are
|
||||||
|
# extension identifiers using the reverse-DNS prefix convention
|
||||||
|
# (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
|
||||||
|
# values are arbitrary extension-defined configuration objects,
|
||||||
|
# with an empty hash meaning "supported with no settings".
|
||||||
|
# Repeated calls merge, so several extensions can be declared independently.
|
||||||
|
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133
|
||||||
|
def support_extensions(extensions = {})
|
||||||
|
@extensions = (@extensions || {}).merge(extensions || {})
|
||||||
|
end
|
||||||
|
|
||||||
def support_logging
|
def support_logging
|
||||||
@logging ||= {}
|
@logging ||= {}
|
||||||
end
|
end
|
||||||
|
|
@ -85,6 +98,7 @@ module MCP
|
||||||
{
|
{
|
||||||
completions: @completions,
|
completions: @completions,
|
||||||
experimental: @experimental,
|
experimental: @experimental,
|
||||||
|
extensions: @extensions,
|
||||||
logging: @logging,
|
logging: @logging,
|
||||||
prompts: @prompts,
|
prompts: @prompts,
|
||||||
resources: @resources,
|
resources: @resources,
|
||||||
|
|
|
||||||
71
test/mcp/server/capabilities_test.rb
Normal file
71
test/mcp/server/capabilities_test.rb
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
module MCP
|
||||||
|
class Server
|
||||||
|
class CapabilitiesTest < ActiveSupport::TestCase
|
||||||
|
test "to_h omits everything by default" do
|
||||||
|
assert_empty(Capabilities.new.to_h)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "constructor accepts a capabilities hash" do
|
||||||
|
capabilities = Capabilities.new(
|
||||||
|
completions: {},
|
||||||
|
logging: {},
|
||||||
|
prompts: { listChanged: true },
|
||||||
|
resources: { listChanged: true, subscribe: true },
|
||||||
|
tools: { listChanged: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal(
|
||||||
|
{
|
||||||
|
completions: {},
|
||||||
|
logging: {},
|
||||||
|
prompts: { listChanged: true },
|
||||||
|
resources: { listChanged: true, subscribe: true },
|
||||||
|
tools: { listChanged: true },
|
||||||
|
},
|
||||||
|
capabilities.to_h,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "constructor accepts extensions" do
|
||||||
|
capabilities = Capabilities.new(
|
||||||
|
tools: {},
|
||||||
|
extensions: { "com.example/feature" => { enabled: true } },
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_equal({ "com.example/feature" => { enabled: true } }, capabilities.to_h[:extensions])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "support_extensions merges repeated declarations" do
|
||||||
|
capabilities = Capabilities.new
|
||||||
|
capabilities.support_extensions("com.example/feature" => { enabled: true })
|
||||||
|
capabilities.support_extensions("io.modelcontextprotocol/tasks" => {})
|
||||||
|
|
||||||
|
assert_equal(
|
||||||
|
{ "com.example/feature" => { enabled: true }, "io.modelcontextprotocol/tasks" => {} },
|
||||||
|
capabilities.to_h[:extensions],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "support_extensions with no arguments declares an empty extensions object" do
|
||||||
|
capabilities = Capabilities.new
|
||||||
|
capabilities.support_extensions
|
||||||
|
|
||||||
|
assert_equal({}, capabilities.to_h[:extensions])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "support_extensions tolerates nil" do
|
||||||
|
capabilities = Capabilities.new(extensions: nil)
|
||||||
|
|
||||||
|
assert_equal({}, capabilities.to_h[:extensions])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "to_h omits extensions when never declared" do
|
||||||
|
refute(Capabilities.new(tools: {}).to_h.key?(:extensions))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -159,6 +159,75 @@ module MCP
|
||||||
assert_instrumentation_data({ method: "initialize" })
|
assert_instrumentation_data({ method: "initialize" })
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "#handle initialize result carries declared capability extensions" do
|
||||||
|
server = Server.new(
|
||||||
|
name: "extensions_test",
|
||||||
|
capabilities: {
|
||||||
|
tools: { listChanged: true },
|
||||||
|
extensions: { "com.example/feature" => { enabled: true } },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 })
|
||||||
|
|
||||||
|
assert_equal(
|
||||||
|
{ "com.example/feature" => { enabled: true } },
|
||||||
|
response.dig(:result, :capabilities, :extensions),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "Server.new accepts an MCP::Server::Capabilities instance" do
|
||||||
|
capabilities = Server::Capabilities.new
|
||||||
|
capabilities.support_tools
|
||||||
|
capabilities.support_extensions("io.modelcontextprotocol/tasks" => {})
|
||||||
|
|
||||||
|
server = Server.new(name: "extensions_test", capabilities: capabilities)
|
||||||
|
response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 })
|
||||||
|
|
||||||
|
assert_equal(
|
||||||
|
{
|
||||||
|
tools: {},
|
||||||
|
extensions: { "io.modelcontextprotocol/tasks" => {} },
|
||||||
|
},
|
||||||
|
response.dig(:result, :capabilities),
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "client-declared capability extensions are readable via client_capabilities" do
|
||||||
|
extensions = { "com.example/feature": { enabled: true } }
|
||||||
|
request = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "initialize",
|
||||||
|
id: 1,
|
||||||
|
params: {
|
||||||
|
clientInfo: { name: "test_client", version: "1.0.0" },
|
||||||
|
capabilities: { extensions: extensions },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@server.handle(request)
|
||||||
|
|
||||||
|
assert_equal extensions, @server.client_capabilities[:extensions]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "client-declared capability extensions are readable via the session" do
|
||||||
|
session = ServerSession.new(server: @server, transport: mock)
|
||||||
|
extensions = { "com.example/feature": {} }
|
||||||
|
request = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
method: "initialize",
|
||||||
|
id: 1,
|
||||||
|
params: {
|
||||||
|
clientInfo: { name: "test_client", version: "1.0.0" },
|
||||||
|
capabilities: { extensions: extensions },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@server.handle(request, session: session)
|
||||||
|
|
||||||
|
assert_equal extensions, session.client_capabilities[:extensions]
|
||||||
|
end
|
||||||
|
|
||||||
test "#handle initialize request with clientInfo includes client in instrumentation data" do
|
test "#handle initialize request with clientInfo includes client in instrumentation data" do
|
||||||
client_info = { name: "test_client", version: "1.0.0" }
|
client_info = { name: "test_client", version: "1.0.0" }
|
||||||
request = {
|
request = {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue