Skip to content

Commit 88e1f9a

Browse files
committed
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.
1 parent 6669601 commit 88e1f9a

File tree

6 files changed

+681
-2
lines changed

6 files changed

+681
-2
lines changed

README.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ It implements the Model Context Protocol specification, handling model context r
5050
- `resources/list` - Lists all registered resources and their schemas
5151
- `resources/read` - Retrieves a specific resource by name
5252
- `resources/templates/list` - Lists all registered resource templates and their schemas
53+
- `completion/complete` - Returns autocompletion suggestions for prompt arguments and resource URIs
5354

5455
### Custom Methods
5556

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

187+
### Completions
188+
189+
MCP spec includes [Completions](https://modelcontextprotocol.io/specification/latest/server/utilities/completion),
190+
which enable servers to provide autocompletion suggestions for prompt arguments and resource URIs.
191+
192+
To enable completions, declare the `completions` capability and register a handler:
193+
194+
```ruby
195+
server = MCP::Server.new(
196+
name: "my_server",
197+
prompts: [CodeReviewPrompt],
198+
resource_templates: [FileTemplate],
199+
capabilities: { completions: {} },
200+
)
201+
202+
server.completion_handler do |params|
203+
ref = params[:ref]
204+
argument = params[:argument]
205+
value = argument[:value]
206+
207+
case ref[:type]
208+
when "ref/prompt"
209+
values = case argument[:name]
210+
when "language"
211+
["python", "pytorch", "pyside"].select { |v| v.start_with?(value) }
212+
else
213+
[]
214+
end
215+
{ completion: { values: values, hasMore: false } }
216+
when "ref/resource"
217+
{ completion: { values: [], hasMore: false } }
218+
end
219+
end
220+
```
221+
222+
The handler receives a `params` hash with:
223+
224+
- `ref` - The reference (`{ type: "ref/prompt", name: "..." }` or `{ type: "ref/resource", uri: "..." }`)
225+
- `argument` - The argument being completed (`{ name: "...", value: "..." }`)
226+
- `context` (optional) - Previously resolved arguments (`{ arguments: { ... } }`)
227+
228+
The handler must return a hash with a `completion` key containing `values` (array of strings), and optionally `total` and `hasMore`.
229+
The SDK automatically enforces the 100-item limit per the MCP specification.
230+
231+
The server validates that the referenced prompt, resource, or resource template is registered before calling the handler.
232+
Requests for unknown references return an error.
233+
186234
### Logging
187235

188236
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).
@@ -298,7 +346,6 @@ transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, session
298346
### Unsupported Features (to be implemented in future versions)
299347

300348
- Resource subscriptions
301-
- Completions
302349
- Elicitation
303350

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

conformance/server.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,7 @@ def configure_handlers(server)
488488
server.server_context = server
489489

490490
configure_resources_read_handler(server)
491+
configure_completion_handler(server)
491492
end
492493

493494
def configure_resources_read_handler(server)
@@ -528,6 +529,35 @@ def configure_resources_read_handler(server)
528529
end
529530
end
530531

532+
def configure_completion_handler(server)
533+
server.completion_handler do |params|
534+
ref = params[:ref]
535+
argument = params[:argument]
536+
value = argument[:value].to_s
537+
538+
case ref[:type]
539+
when "ref/prompt"
540+
case ref[:name]
541+
when "test_prompt_with_arguments"
542+
candidates = case argument[:name]
543+
when "arg1"
544+
["value1", "value2", "value3"]
545+
when "arg2"
546+
["optionA", "optionB", "optionC"]
547+
else
548+
[]
549+
end
550+
values = candidates.select { |v| v.start_with?(value) }
551+
{ completion: { values: values, hasMore: false } }
552+
else
553+
{ completion: { values: [], hasMore: false } }
554+
end
555+
else
556+
{ completion: { values: [], hasMore: false } }
557+
end
558+
end
559+
end
560+
531561
def build_rack_app(transport)
532562
mcp_app = proc do |env|
533563
request = Rack::Request.new(env)

lib/mcp/client.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,22 @@ def get_prompt(name:)
147147
response.fetch("result", {})
148148
end
149149

150+
# Requests completion suggestions from the server for a prompt argument or resource template URI.
151+
#
152+
# @param ref [Hash] The reference, e.g. `{ type: "ref/prompt", name: "my_prompt" }`
153+
# or `{ type: "ref/resource", uri: "file:///{path}" }`.
154+
# @param argument [Hash] The argument being completed, e.g. `{ name: "language", value: "py" }`.
155+
# @param context [Hash, nil] Optional context with previously resolved arguments.
156+
# @return [Hash] The completion result with `"values"`, `"hasMore"`, and optionally `"total"`.
157+
def complete(ref:, argument:, context: nil)
158+
params = { ref: ref, argument: argument }
159+
params[:context] = context if context
160+
161+
response = request(method: "completion/complete", params: params)
162+
163+
response.dig("result", "completion") || { "values" => [], "hasMore" => false }
164+
end
165+
150166
private
151167

152168
def request(method:, params: nil)

lib/mcp/server.rb

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ class Server
2424
UNSUPPORTED_PROPERTIES_UNTIL_2025_06_18 = [:description, :icons].freeze
2525
UNSUPPORTED_PROPERTIES_UNTIL_2025_03_26 = [:title, :websiteUrl].freeze
2626

27+
DEFAULT_COMPLETION_RESULT = { completion: { values: [], hasMore: false } }.freeze
28+
29+
# Servers return an array of completion values ranked by relevance, with maximum 100 items per response.
30+
# https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion#completion-results
31+
MAX_COMPLETION_VALUES = 100
32+
2733
class RequestHandlerError < StandardError
2834
attr_reader :error_type
2935
attr_reader :original_error
@@ -100,12 +106,12 @@ def initialize(
100106
Methods::PING => ->(_) { {} },
101107
Methods::NOTIFICATIONS_INITIALIZED => ->(_) {},
102108
Methods::NOTIFICATIONS_PROGRESS => ->(_) {},
109+
Methods::COMPLETION_COMPLETE => ->(_) { DEFAULT_COMPLETION_RESULT },
103110
Methods::LOGGING_SET_LEVEL => method(:configure_logging_level),
104111

105112
# No op handlers for currently unsupported methods
106113
Methods::RESOURCES_SUBSCRIBE => ->(_) { {} },
107114
Methods::RESOURCES_UNSUBSCRIBE => ->(_) { {} },
108-
Methods::COMPLETION_COMPLETE => ->(_) { { completion: { values: [], hasMore: false } } },
109115
Methods::ELICITATION_CREATE => ->(_) {},
110116
}
111117
@transport = transport
@@ -208,6 +214,15 @@ def resources_read_handler(&block)
208214
@handlers[Methods::RESOURCES_READ] = block
209215
end
210216

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

213228
def validate!
@@ -307,6 +322,8 @@ def handle_request(request, method, session: nil)
307322
{ resourceTemplates: @handlers[Methods::RESOURCES_TEMPLATES_LIST].call(params) }
308323
when Methods::TOOLS_CALL
309324
call_tool(params, session: session)
325+
when Methods::COMPLETION_COMPLETE
326+
complete(params)
310327
when Methods::LOGGING_SET_LEVEL
311328
configure_logging_level(params, session: session)
312329
else
@@ -481,6 +498,14 @@ def list_resource_templates(request)
481498
@resource_templates.map(&:to_h)
482499
end
483500

501+
def complete(params)
502+
validate_completion_params!(params)
503+
504+
result = @handlers[Methods::COMPLETION_COMPLETE].call(params)
505+
506+
normalize_completion_result(result)
507+
end
508+
484509
def report_exception(exception, server_context = {})
485510
configuration.exception_reporter.call(exception, server_context)
486511
end
@@ -539,5 +564,56 @@ def server_context_with_meta(request)
539564
server_context
540565
end
541566
end
567+
568+
def validate_completion_params!(params)
569+
unless params.is_a?(Hash)
570+
raise RequestHandlerError.new("Invalid params", params, error_type: :invalid_params)
571+
end
572+
573+
ref = params[:ref]
574+
if ref.nil? || ref[:type].nil?
575+
raise RequestHandlerError.new("Missing or invalid ref", params, error_type: :invalid_params)
576+
end
577+
578+
argument = params[:argument]
579+
if argument.nil? || argument[:name].nil? || !argument.key?(:value)
580+
raise RequestHandlerError.new("Missing argument name or value", params, error_type: :invalid_params)
581+
end
582+
583+
case ref[:type]
584+
when "ref/prompt"
585+
unless @prompts[ref[:name]]
586+
raise RequestHandlerError.new("Prompt not found: #{ref[:name]}", params, error_type: :invalid_params)
587+
end
588+
when "ref/resource"
589+
uri = ref[:uri]
590+
found = @resource_index.key?(uri) || @resource_templates.any? { |t| t.uri_template == uri }
591+
unless found
592+
raise RequestHandlerError.new("Resource not found: #{uri}", params, error_type: :invalid_params)
593+
end
594+
else
595+
raise RequestHandlerError.new("Invalid ref type: #{ref[:type]}", params, error_type: :invalid_params)
596+
end
597+
end
598+
599+
def normalize_completion_result(result)
600+
return DEFAULT_COMPLETION_RESULT unless result.is_a?(Hash)
601+
602+
completion = result[:completion] || result["completion"]
603+
return DEFAULT_COMPLETION_RESULT unless completion.is_a?(Hash)
604+
605+
values = completion[:values] || completion["values"] || []
606+
total = completion[:total] || completion["total"]
607+
has_more = completion[:hasMore] || completion["hasMore"] || false
608+
609+
count = values.length
610+
if count > MAX_COMPLETION_VALUES
611+
has_more = true
612+
total ||= count
613+
values = values.first(MAX_COMPLETION_VALUES)
614+
end
615+
616+
{ completion: { values: values, total: total, hasMore: has_more }.compact }
617+
end
542618
end
543619
end

test/mcp/client_test.rb

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,5 +456,86 @@ def test_server_error_includes_data_field
456456
error = assert_raises(Client::ServerError) { client.tools }
457457
assert_equal("extra details", error.data)
458458
end
459+
460+
def test_complete_raises_server_error_on_error_response
461+
transport = mock
462+
mock_response = { "error" => { "code" => -32_602, "message" => "Invalid params" } }
463+
464+
transport.expects(:send_request).returns(mock_response).once
465+
466+
client = Client.new(transport: transport)
467+
error = assert_raises(Client::ServerError) { client.complete(ref: { type: "ref/prompt", name: "missing" }, argument: { name: "arg", value: "" }) }
468+
assert_equal(-32_602, error.code)
469+
end
470+
471+
def test_complete_sends_request_and_returns_completion_result
472+
transport = mock
473+
mock_response = {
474+
"result" => {
475+
"completion" => {
476+
"values" => ["python", "pytorch"],
477+
"hasMore" => false,
478+
},
479+
},
480+
}
481+
482+
transport.expects(:send_request).with do |args|
483+
args.dig(:request, :method) == "completion/complete" &&
484+
args.dig(:request, :jsonrpc) == "2.0" &&
485+
args.dig(:request, :params, :ref) == { type: "ref/prompt", name: "code_review" } &&
486+
args.dig(:request, :params, :argument) == { name: "language", value: "py" } &&
487+
!args.dig(:request, :params).key?(:context)
488+
end.returns(mock_response).once
489+
490+
client = Client.new(transport: transport)
491+
result = client.complete(
492+
ref: { type: "ref/prompt", name: "code_review" },
493+
argument: { name: "language", value: "py" },
494+
)
495+
496+
assert_equal(["python", "pytorch"], result["values"])
497+
refute(result["hasMore"])
498+
end
499+
500+
def test_complete_includes_context_when_provided
501+
transport = mock
502+
mock_response = {
503+
"result" => {
504+
"completion" => {
505+
"values" => ["flask"],
506+
"hasMore" => false,
507+
},
508+
},
509+
}
510+
511+
transport.expects(:send_request).with do |args|
512+
args.dig(:request, :params, :context) == { arguments: { language: "python" } }
513+
end.returns(mock_response).once
514+
515+
client = Client.new(transport: transport)
516+
result = client.complete(
517+
ref: { type: "ref/prompt", name: "code_review" },
518+
argument: { name: "framework", value: "fla" },
519+
context: { arguments: { language: "python" } },
520+
)
521+
522+
assert_equal(["flask"], result["values"])
523+
end
524+
525+
def test_complete_returns_default_when_result_is_missing
526+
transport = mock
527+
mock_response = { "result" => {} }
528+
529+
transport.expects(:send_request).returns(mock_response).once
530+
531+
client = Client.new(transport: transport)
532+
result = client.complete(
533+
ref: { type: "ref/prompt", name: "test" },
534+
argument: { name: "arg", value: "" },
535+
)
536+
537+
assert_equal([], result["values"])
538+
refute(result["hasMore"])
539+
end
459540
end
460541
end

0 commit comments

Comments
 (0)