Skip to content

Commit c563036

Browse files
authored
Merge pull request #289 from koic/feature_completion_complete
Support `completion/complete` per MCP specification
2 parents 52371a3 + 88e1f9a commit c563036

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)