diff --git a/README.md b/README.md index d6154294..31faa245 100644 --- a/README.md +++ b/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` diff --git a/lib/mcp/client.rb b/lib/mcp/client.rb index ea71d010..68c2ee4d 100644 --- a/lib/mcp/client.rb +++ b/lib/mcp/client.rb @@ -77,7 +77,9 @@ def server_info # # @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 diff --git a/lib/mcp/server.rb b/lib/mcp/server.rb index f93bea85..e734b7bb 100644 --- a/lib/mcp/server.rb +++ b/lib/mcp/server.rb @@ -8,6 +8,7 @@ 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 @@ def initialize( 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 diff --git a/lib/mcp/server/capabilities.rb b/lib/mcp/server/capabilities.rb index 6647eb90..0efe015b 100644 --- a/lib/mcp/server/capabilities.rb +++ b/lib/mcp/server/capabilities.rb @@ -6,6 +6,7 @@ class Capabilities def initialize(capabilities_hash = nil) @completions = nil @experimental = nil + @extensions = nil @logging = nil @prompts = nil @resources = nil @@ -14,6 +15,7 @@ def initialize(capabilities_hash = nil) 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 @@ def support_experimental(config = {}) @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 @@ def to_h { completions: @completions, experimental: @experimental, + extensions: @extensions, logging: @logging, prompts: @prompts, resources: @resources, diff --git a/test/mcp/server/capabilities_test.rb b/test/mcp/server/capabilities_test.rb new file mode 100644 index 00000000..93e7e5fa --- /dev/null +++ b/test/mcp/server/capabilities_test.rb @@ -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 diff --git a/test/mcp/server_test.rb b/test/mcp/server_test.rb index b40f604e..43ece74e 100644 --- a/test/mcp/server_test.rb +++ b/test/mcp/server_test.rb @@ -159,6 +159,75 @@ class ServerTest < ActiveSupport::TestCase 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 = {