Merge pull request #405 from koic/capability_extensions

Support Capability Extensions per SEP-2133
This commit is contained in:
Koichi ITO 2026-06-15 00:09:25 +09:00 • committed by GitHub
commit 7f266d48ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 189 additions and 2 deletions

View file

@ -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`

View file

@ -77,7 +77,9 @@ module MCP
#
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
# @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
# does not expose an explicit handshake.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization

View file

@ -8,6 +8,7 @@ require_relative "methods"
require_relative "logging_message_notification"
require_relative "progress"
require_relative "server_context"
require_relative "server/capabilities"
require_relative "server/pagination"
require_relative "server/transports"
@ -142,7 +143,12 @@ module MCP
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
@logging_message_notification = nil

View file

@ -6,6 +6,7 @@ module MCP
def initialize(capabilities_hash = nil)
@completions = nil
@experimental = nil
@extensions = nil
@logging = nil
@prompts = nil
@resources = nil
@ -14,6 +15,7 @@ module MCP
if capabilities_hash
support_completions if capabilities_hash.key?(:completions)
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)
if capabilities_hash.key?(:prompts)
@ -45,6 +47,17 @@ module MCP
@experimental = config || {}
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
@logging ||= {}
end
@ -85,6 +98,7 @@ module MCP
{
completions: @completions,
experimental: @experimental,
extensions: @extensions,
logging: @logging,
prompts: @prompts,
resources: @resources,

View 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

View file

@ -159,6 +159,75 @@ module MCP
assert_instrumentation_data({ method: "initialize" })
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
client_info = { name: "test_client", version: "1.0.0" }
request = {