Skip to content

Commit 2c62139

Browse files
committed
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 modelcontextprotocol#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.
1 parent 3a163ad commit 2c62139

6 files changed

Lines changed: 189 additions & 2 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,31 @@ server = MCP::Server.new(
231231
)
232232
```
233233

234+
### Capability Extensions
235+
236+
Per SEP-2133, both clients and servers can declare protocol extensions under the `extensions` member of their capabilities.
237+
Keys are extension identifiers using the reverse-DNS prefix convention (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
238+
values are extension-defined configuration objects, with `{}` meaning "supported with no settings".
239+
240+
On the server, declare extensions through the `capabilities` keyword, either as a plain hash or via the `MCP::Server::Capabilities` builder:
241+
242+
```ruby
243+
capabilities = MCP::Server::Capabilities.new
244+
capabilities.support_tools
245+
capabilities.support_extensions("com.example/feature" => { enabled: true })
246+
247+
server = MCP::Server.new(name: "my_server", capabilities: capabilities)
248+
```
249+
250+
The declared extensions appear in the `initialize` result's `capabilities.extensions`. Extensions the client declared during `initialize` are
251+
readable via `server.client_capabilities[:extensions]` (or `session.client_capabilities[:extensions]` for per-session transports).
252+
253+
On the client, pass extensions through `connect`:
254+
255+
```ruby
256+
client.connect(capabilities: { extensions: { "com.example/feature" => {} } })
257+
```
258+
234259
### Server Context and Configuration Block Data
235260

236261
#### `server_context`

lib/mcp/client.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@ def server_info
7777
#
7878
# @param client_info [Hash, nil] `{ name:, version: }` identifying the client.
7979
# @param protocol_version [String, nil] Protocol version to offer.
80-
# @param capabilities [Hash] Capabilities advertised by the client.
80+
# @param capabilities [Hash] Capabilities advertised by the client. May include
81+
# an `extensions` member per SEP-2133, keyed by reverse-DNS extension identifiers,
82+
# e.g. `{ extensions: { "com.example/feature" => {} } }`.
8183
# @return [Hash, nil] The server's `InitializeResult`, or `nil` when the transport
8284
# does not expose an explicit handshake.
8385
# https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle#initialization

lib/mcp/server.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require_relative "logging_message_notification"
99
require_relative "progress"
1010
require_relative "server_context"
11+
require_relative "server/capabilities"
1112
require_relative "server/pagination"
1213
require_relative "server/transports"
1314

@@ -142,7 +143,12 @@ def initialize(
142143

143144
validate!
144145

145-
@capabilities = capabilities || default_capabilities
146+
# Accept either a plain Hash or an `MCP::Server::Capabilities` builder.
147+
@capabilities = if capabilities.is_a?(Capabilities)
148+
capabilities.to_h
149+
else
150+
capabilities || default_capabilities
151+
end
146152
@client_capabilities = nil
147153
@logging_message_notification = nil
148154

lib/mcp/server/capabilities.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ class Capabilities
66
def initialize(capabilities_hash = nil)
77
@completions = nil
88
@experimental = nil
9+
@extensions = nil
910
@logging = nil
1011
@prompts = nil
1112
@resources = nil
@@ -14,6 +15,7 @@ def initialize(capabilities_hash = nil)
1415
if capabilities_hash
1516
support_completions if capabilities_hash.key?(:completions)
1617
support_experimental(capabilities_hash[:experimental]) if capabilities_hash.key?(:experimental)
18+
support_extensions(capabilities_hash[:extensions]) if capabilities_hash.key?(:extensions)
1719
support_logging if capabilities_hash.key?(:logging)
1820

1921
if capabilities_hash.key?(:prompts)
@@ -45,6 +47,17 @@ def support_experimental(config = {})
4547
@experimental = config || {}
4648
end
4749

50+
# Declares support for capability extensions per SEP-2133. Keys are
51+
# extension identifiers using the reverse-DNS prefix convention
52+
# (e.g. `"io.modelcontextprotocol/tasks"`, `"com.example/feature"`);
53+
# values are arbitrary extension-defined configuration objects,
54+
# with an empty hash meaning "supported with no settings".
55+
# Repeated calls merge, so several extensions can be declared independently.
56+
# https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133
57+
def support_extensions(extensions = {})
58+
@extensions = (@extensions || {}).merge(extensions || {})
59+
end
60+
4861
def support_logging
4962
@logging ||= {}
5063
end
@@ -85,6 +98,7 @@ def to_h
8598
{
8699
completions: @completions,
87100
experimental: @experimental,
101+
extensions: @extensions,
88102
logging: @logging,
89103
prompts: @prompts,
90104
resources: @resources,
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
module MCP
6+
class Server
7+
class CapabilitiesTest < ActiveSupport::TestCase
8+
test "to_h omits everything by default" do
9+
assert_empty(Capabilities.new.to_h)
10+
end
11+
12+
test "constructor accepts a capabilities hash" do
13+
capabilities = Capabilities.new(
14+
completions: {},
15+
logging: {},
16+
prompts: { listChanged: true },
17+
resources: { listChanged: true, subscribe: true },
18+
tools: { listChanged: true },
19+
)
20+
21+
assert_equal(
22+
{
23+
completions: {},
24+
logging: {},
25+
prompts: { listChanged: true },
26+
resources: { listChanged: true, subscribe: true },
27+
tools: { listChanged: true },
28+
},
29+
capabilities.to_h,
30+
)
31+
end
32+
33+
test "constructor accepts extensions" do
34+
capabilities = Capabilities.new(
35+
tools: {},
36+
extensions: { "com.example/feature" => { enabled: true } },
37+
)
38+
39+
assert_equal({ "com.example/feature" => { enabled: true } }, capabilities.to_h[:extensions])
40+
end
41+
42+
test "support_extensions merges repeated declarations" do
43+
capabilities = Capabilities.new
44+
capabilities.support_extensions("com.example/feature" => { enabled: true })
45+
capabilities.support_extensions("io.modelcontextprotocol/tasks" => {})
46+
47+
assert_equal(
48+
{ "com.example/feature" => { enabled: true }, "io.modelcontextprotocol/tasks" => {} },
49+
capabilities.to_h[:extensions],
50+
)
51+
end
52+
53+
test "support_extensions with no arguments declares an empty extensions object" do
54+
capabilities = Capabilities.new
55+
capabilities.support_extensions
56+
57+
assert_equal({}, capabilities.to_h[:extensions])
58+
end
59+
60+
test "support_extensions tolerates nil" do
61+
capabilities = Capabilities.new(extensions: nil)
62+
63+
assert_equal({}, capabilities.to_h[:extensions])
64+
end
65+
66+
test "to_h omits extensions when never declared" do
67+
refute(Capabilities.new(tools: {}).to_h.key?(:extensions))
68+
end
69+
end
70+
end
71+
end

test/mcp/server_test.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,75 @@ class ServerTest < ActiveSupport::TestCase
159159
assert_instrumentation_data({ method: "initialize" })
160160
end
161161

162+
test "#handle initialize result carries declared capability extensions" do
163+
server = Server.new(
164+
name: "extensions_test",
165+
capabilities: {
166+
tools: { listChanged: true },
167+
extensions: { "com.example/feature" => { enabled: true } },
168+
},
169+
)
170+
171+
response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 })
172+
173+
assert_equal(
174+
{ "com.example/feature" => { enabled: true } },
175+
response.dig(:result, :capabilities, :extensions),
176+
)
177+
end
178+
179+
test "Server.new accepts an MCP::Server::Capabilities instance" do
180+
capabilities = Server::Capabilities.new
181+
capabilities.support_tools
182+
capabilities.support_extensions("io.modelcontextprotocol/tasks" => {})
183+
184+
server = Server.new(name: "extensions_test", capabilities: capabilities)
185+
response = server.handle({ jsonrpc: "2.0", method: "initialize", id: 1 })
186+
187+
assert_equal(
188+
{
189+
tools: {},
190+
extensions: { "io.modelcontextprotocol/tasks" => {} },
191+
},
192+
response.dig(:result, :capabilities),
193+
)
194+
end
195+
196+
test "client-declared capability extensions are readable via client_capabilities" do
197+
extensions = { "com.example/feature": { enabled: true } }
198+
request = {
199+
jsonrpc: "2.0",
200+
method: "initialize",
201+
id: 1,
202+
params: {
203+
clientInfo: { name: "test_client", version: "1.0.0" },
204+
capabilities: { extensions: extensions },
205+
},
206+
}
207+
208+
@server.handle(request)
209+
210+
assert_equal extensions, @server.client_capabilities[:extensions]
211+
end
212+
213+
test "client-declared capability extensions are readable via the session" do
214+
session = ServerSession.new(server: @server, transport: mock)
215+
extensions = { "com.example/feature": {} }
216+
request = {
217+
jsonrpc: "2.0",
218+
method: "initialize",
219+
id: 1,
220+
params: {
221+
clientInfo: { name: "test_client", version: "1.0.0" },
222+
capabilities: { extensions: extensions },
223+
},
224+
}
225+
226+
@server.handle(request, session: session)
227+
228+
assert_equal extensions, session.client_capabilities[:extensions]
229+
end
230+
162231
test "#handle initialize request with clientInfo includes client in instrumentation data" do
163232
client_info = { name: "test_client", version: "1.0.0" }
164233
request = {

0 commit comments

Comments
 (0)