Skip to content
Merged
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
50 changes: 49 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ It implements the Model Context Protocol specification, handling model context r
- `resources/list` - Lists all registered resources and their schemas
- `resources/read` - Retrieves a specific resource by name
- `resources/templates/list` - Lists all registered resource templates and their schemas
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs

### Custom Methods

Expand Down Expand Up @@ -183,6 +184,53 @@ The `server_context.report_progress` method accepts:
- `report_progress` is a no-op when no `progressToken` was provided by the client
- Supports both numeric and string progress tokens

### Completions

MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion),
which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs.

To enable completions, declare the `completions` capability and register a handler:

```ruby
server = MCP::Server.new(
name: "my_server",
prompts: [CodeReviewPrompt],
resource_templates: [FileTemplate],
capabilities: { completions: {} },
)

server.completion_handler do |params|
ref = params[:ref]
argument = params[:argument]
value = argument[:value]

case ref[:type]
when "ref/prompt"
values = case argument[:name]
when "language"
["python", "pytorch", "pyside"].select { |v| v.start_with?(value) }
else
[]
end
{ completion: { values: values, hasMore: false } }
when "ref/resource"
{ completion: { values: [], hasMore: false } }
end
end
```

The handler receives a `params` hash with:

- `ref` - The reference (`{ type: "ref/prompt", name: "..." }` or `{ type: "ref/resource", uri: "..." }`)
- `argument` - The argument being completed (`{ name: "...", value: "..." }`)
- `context` (optional) - Previously resolved arguments (`{ arguments: { ... } }`)

The handler must return a hash with a `completion` key containing `values` (array of strings), and optionally `total` and `hasMore`.
The SDK automatically enforces the 100-item limit per the MCP specification.

The server validates that the referenced prompt, resource, or resource template is registered before calling the handler.
Requests for unknown references return an error.

### Logging

The MCP Ruby SDK supports structured logging through the `notify_log_message` method, following the [MCP Logging specification](https://modelcontextprotocol.io/specification/latest/server/utilities/logging).
Expand Down Expand Up @@ -298,7 +346,6 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session
### Unsupported Features (to be implemented in future versions)

- Resource subscriptions
- Completions
- Elicitation

### Usage
Expand Down Expand Up @@ -1056,6 +1103,7 @@ This class supports:
- Resource reading via the `resources/read` method (`MCP::Client#read_resources`)
- Prompt listing via the `prompts/list` method (`MCP::Client#prompts`)
- Prompt retrieval via the `prompts/get` method (`MCP::Client#get_prompt`)
- Completion requests via the `completion/complete` method (`MCP::Client#complete`)
- Automatic JSON-RPC 2.0 message formatting
- UUID request ID generation

Expand Down
30 changes: 30 additions & 0 deletions conformance/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,7 @@ def configure_handlers(server)
server.server_context = server

configure_resources_read_handler(server)
configure_completion_handler(server)
end

def configure_resources_read_handler(server)
Expand Down Expand Up @@ -528,6 +529,35 @@ def configure_resources_read_handler(server)
end
end

def configure_completion_handler(server)
server.completion_handler do |params|
ref = params[:ref]
argument = params[:argument]
value = argument[:value].to_s

case ref[:type]
when "ref/prompt"
case ref[:name]
when "test_prompt_with_arguments"
candidates = case argument[:name]
when "arg1"
["value1", "value2", "value3"]
when "arg2"
["optionA", "optionB", "optionC"]
else
[]
end
values = candidates.select { |v| v.start_with?(value) }
{ completion: { values: values, hasMore: false } }
else
{ completion: { values: [], hasMore: false } }
end
else
{ completion: { values: [], hasMore: false } }
end
end
end

def build_rack_app(transport)
mcp_app = proc do |env|
request = Rack::Request.new(env)
Expand Down
16 changes: 16 additions & 0 deletions lib/mcp/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,22 @@ def get_prompt(name:)
response.fetch("result", {})
end

# Requests completion suggestions from the server for a prompt argument or resource template URI.
#
# @param ref [Hash] The reference, e.g. `{ type: "ref/prompt", name: "my_prompt" }`
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
# @param context [Hash, nil] Optional context with previously resolved arguments.
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
def complete(ref:, argument:, context: nil)
params = { ref: ref, argument: argument }
params[:context] = context if context

response = request(method: "completion/complete", params: params)

response.dig("result", "completion") || { "values" => [], "hasMore" => false }
end

private

def request(method:, params: nil)
Expand Down
78 changes: 77 additions & 1 deletion lib/mcp/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ class Server
UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18 = [:description, :icons].freeze
UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26 = [:title, :websiteUrl].freeze

DEFAULT_COMPLETION_RESULT = { completion: { values: [], hasMore: false } }.freeze

# Servers return an array of completion values ranked by relevance, with maximum 100 items per response.
# https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#completion-results
MAX_COMPLETION_VALUES = 100

class RequestHandlerError < StandardError
attr_reader :error_type
attr_reader :original_error
Expand Down Expand Up @@ -100,12 +106,12 @@ def initialize(
Methods::PING => ->(_) { {} },
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),

# No op handlers for currently unsupported methods
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } },
Methods::ELICITATION_CREATE => ->(_) {},
}
@transport = transport
Expand Down Expand Up @@ -208,6 +214,15 @@ def resources_read_handler(&block)
@handlers[Methods::RESOURCES_READ] = block
end

# Sets a custom handler for `completion/complete` requests.
# The block receives the parsed request params and should return completion values.
#
# @yield [params] The request params containing `:ref`, `:argument`, and optionally `:context`.
# @yieldreturn [Hash] A hash with `:completion` key containing `:values`, optional `:total`, and `:hasMore`.
def completion_handler(&block)
@handlers[Methods::COMPLETION_COMPLETE] = block
end

private

def validate!
Expand Down Expand Up @@ -307,6 +322,8 @@ def handle_request(request, method, session: nil)
{ resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
when Methods::TOOLS_CALL
call_tool(params, session: session)
when Methods::COMPLETION_COMPLETE
complete(params)
when Methods::LOGGING_SET_LEVEL
configure_logging_level(params, session: session)
else
Expand Down Expand Up @@ -481,6 +498,14 @@ def list_resource_templates(request)
@resource_templates.map(&:to_h)
end

def complete(params)
validate_completion_params!(params)

result = @handlers[Methods::COMPLETION_COMPLETE].call(params)

normalize_completion_result(result)
end

def report_exception(exception, server_context = {})
configuration.exception_reporter.call(exception, server_context)
end
Expand Down Expand Up @@ -539,5 +564,56 @@ def server_context_with_meta(request)
server_context
end
end

def validate_completion_params!(params)
unless params.is_a?(Hash)
raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
end

ref = params[:ref]
if ref.nil? || ref[:type].nil?
raise RequestHandlerError.new("Missing or invalid ref", params, error_type: :invalid_params)
end

argument = params[:argument]
if argument.nil? || argument[:name].nil? || !argument.key?(:value)
raise RequestHandlerError.new("Missing argument name or value", params, error_type: :invalid_params)
end

case ref[:type]
when "ref/prompt"
unless @prompts[ref[:name]]
raise RequestHandlerError.new("Prompt not found: #{ref[:name]}", params, error_type: :invalid_params)
end
when "ref/resource"
uri = ref[:uri]
found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri }
unless found
raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params)
end
else
raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params)
end
end

def normalize_completion_result(result)
return DEFAULT_COMPLETION_RESULT unless result.is_a?(Hash)

completion = result[:completion] || result["completion"]
return DEFAULT_COMPLETION_RESULT unless completion.is_a?(Hash)

values = completion[:values] || completion["values"] || []
total = completion[:total] || completion["total"]
has_more = completion[:hasMore] || completion["hasMore"] || false

count = values.length
if count > MAX_COMPLETION_VALUES
has_more = true
total ||= count
values = values.first(MAX_COMPLETION_VALUES)
end

{ completion: { values: values, total: total, hasMore: has_more }.compact }
end
end
end
81 changes: 81 additions & 0 deletions test/mcp/client_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -456,5 +456,86 @@ def test_server_error_includes_data_field
error = assert_raises(Client::ServerError) { client.tools }
assert_equal("extra details", error.data)
end

def test_complete_raises_server_error_on_error_response
transport = mock
mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } }

transport.expects(:send_request).returns(mock_response).once

client = Client.new(transport: transport)
error = assert_raises(Client::ServerError) { client.complete(ref: { type: "ref/prompt", name: "missing" }, argument: { name: "arg", value: "" }) }
assert_equal(-32_602, error.code)
end

def test_complete_sends_request_and_returns_completion_result
transport = mock
mock_response = {
"result" => {
"completion" => {
"values" => ["python", "pytorch"],
"hasMore" => false,
},
},
}

transport.expects(:send_request).with do |args|
args.dig(:request, :method) == "completion/complete" &&
args.dig(:request, :jsonrpc) == "2.0" &&
args.dig(:request, :params, :ref) == { type: "ref/prompt", name: "code_review" } &&
args.dig(:request, :params, :argument) == { name: "language", value: "py" } &&
!args.dig(:request, :params).key?(:context)
end.returns(mock_response).once

client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "code_review" },
argument: { name: "language", value: "py" },
)

assert_equal(["python", "pytorch"], result["values"])
refute(result["hasMore"])
end

def test_complete_includes_context_when_provided
transport = mock
mock_response = {
"result" => {
"completion" => {
"values" => ["flask"],
"hasMore" => false,
},
},
}

transport.expects(:send_request).with do |args|
args.dig(:request, :params, :context) == { arguments: { language: "python" } }
end.returns(mock_response).once

client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "code_review" },
argument: { name: "framework", value: "fla" },
context: { arguments: { language: "python" } },
)

assert_equal(["flask"], result["values"])
end

def test_complete_returns_default_when_result_is_missing
transport = mock
mock_response = { "result" => {} }

transport.expects(:send_request).returns(mock_response).once

client = Client.new(transport: transport)
result = client.complete(
ref: { type: "ref/prompt", name: "test" },
argument: { name: "arg", value: "" },
)

assert_equal([], result["values"])
refute(result["hasMore"])
end
end
end
Loading