Support completion/complete per MCP specification
## Motivation and Context The MCP specification defines `completion/complete` for providing autocompletion suggestions for prompt arguments and resource template URIs. The Ruby SDK previously had only a no-op handler that returned empty results, with no way for users to define custom completion logic. This aligns the Ruby SDK with the Python and TypeScript SDKs, both of which support user-defined completion handlers. ## How Has This Been Tested? Server tests cover: default handler, custom handler for `ref/prompt` and `ref/resource`, context argument passing, 100-item truncation, and error responses for nonexistent prompts, nonexistent resource templates, and invalid ref types. Client tests cover: request structure, context parameter inclusion, and fallback when result is missing. ## Breaking Changes None. The existing default no-op handler behavior is preserved. The `completion_handler` method is purely additive. The only behavioral change is that `completion/complete` requests now validate that the referenced prompt or resource template exists before calling the handler, returning an `invalid_params` error for unknown references.
This commit is contained in:
parent
6669601b5d
commit
88e1f9a840
2 changed files with 681 additions and 2 deletions
|
|
@ -147,6 +147,22 @@ module MCP
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ module MCP
|
|||
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
|
||||
|
|
@ -100,12 +106,12 @@ module MCP
|
|||
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
|
||||
|
|
@ -208,6 +214,15 @@ module MCP
|
|||
@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!
|
||||
|
|
@ -307,6 +322,8 @@ module MCP
|
|||
{ 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
|
||||
|
|
@ -481,6 +498,14 @@ module MCP
|
|||
@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
|
||||
|
|
@ -539,5 +564,56 @@ module MCP
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue