Skip to content

Commit a75ab5c

Browse files
committed
feat(server,lambda): route completion/complete through registered providers
McpCompletion providers registered via .completion_provider(...) were stored but never consulted — both McpServerBuilder and LambdaMcpServerBuilder registered a static CompletionHandler that ignored its input and answered ["example1","example2"] (red phase showed the placeholder verbatim on the wire). - CompletionHandler now parses typed params (2026 CompleteRequestParams / frozen-2025 CompleteParams, lane-split) — malformed input → -32602, including reference-type literals "ref/prompt"/"ref/resource" the untagged union would otherwise accept open-ended. - Deterministic routing: exact reference match first, can_handle fallback; priority desc then insertion order (provider storage HashMap → Vec — the first implementation was order-flaky and the tests caught it). - Provider validate_request runs on the live path; the spec's 100-item completion.values cap is enforced (truncation sets total/hasMore). - No matching provider → empty values; placeholder removed on both lanes. Tests: completion_complete_routes_to_registered_provider, completion_values_are_capped_at_100, malformed_completion_params_are_rejected_with_32602 (+ existing shape test). Revert-and-fail: red phase recorded before the handler rewrite. Gates: scripts/ci-gates.sh all → ALL GATES PASSED (from-clean rebuild). Closes spec-compliance driver gaps UTIL/COMP-1 (P1) + UTIL/COMP-3 (P2).
1 parent 8fd899c commit a75ab5c

7 files changed

Lines changed: 334 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2020
2121
### Added
2222

23+
- **`completion/complete` now dispatches to registered `McpCompletion` providers (2026-06-11).** Providers registered via `.completion_provider(...)` were stored but never consulted — the handler always answered with hardcoded placeholder values and ignored its input. The handler now parses typed `CompleteRequestParams` (malformed input → `-32602`, including reference-type literals `"ref/prompt"`/`"ref/resource"` that the untagged union would otherwise accept open-ended), routes deterministically (exact reference match first, `can_handle` fallback; priority desc then insertion order — provider storage moved from `HashMap` to `Vec` to make the tiebreak stable), runs the provider's `validate_request`, and enforces the spec's 100-item `completion.values` cap (truncation sets `total`/`hasMore`). No matching provider → empty values (the placeholder junk is gone). The same gap existed verbatim in `LambdaMcpServerBuilder` (providers stored, static handler answered) — mirrored fix there. Closes spec-compliance driver gaps **UTIL/COMP-1 (P1)** and **UTIL/COMP-3 (P2)**; red-phase wire tests in `discover_stateless_2026.rs`.
2324
- **Mode-aware MRTR capability gating (2026-06-11).** The server's `-32003` gate on `InputRequiredResult` now enforces sub-capabilities, not just top-level presence: URL-mode elicitation requires the client's `elicitation.url` declaration ("Servers MUST NOT send elicitation requests with modes that are not supported by the client"; an empty `elicitation: {}` declares form-only), and tool-enabled sampling (`tools`/`toolChoice` present) requires `sampling.tools` ("Servers MUST NOT send tool-enabled sampling requests to Clients that have not declared support"). Closes spec-compliance driver gaps **CF/GAP-CF-1 + CF/GAP-CF-2 (both P1)**; red-phase wire tests in `mrtr_2026.rs`.
2425
- **`roots/list` removed from the 2026 default surface (2026-06-11).** On 2026-07-28, roots is a client feature: the server requests roots via MRTR input requests and never hosts an inbound `roots/list` RPC; `notifications/roots/list_changed` has no binding in the pinned schema. The builder's `roots/list` + roots-notification registrations are now gated to the `protocol-2025-11-25` opt-in; on the 2026 default they answer 404 + `-32601` like every other non-2026 method. Closes spec-compliance driver gap **CF/GAP-CF-4 (P1)**; red-phase recorded in `error_mapping_2026.rs`.
2526
- **Client MRTR completion for `resources/read` + `prompts/get`, and `resultType` discipline (2026-06-11, ADR-030 revision log).** The bilingual client's `parse_read_resource`/`parse_get_prompt` now surface `InputRequiredResult` as `McpClientError::InputRequired` (previously a serde "missing field" error that discarded `inputRequests`/`requestState`), with retry APIs `read_resource_with_input_responses` / `get_prompt_with_input_responses` mirroring `call_tool_with_input_responses`. All 2026 result parsers now enforce basic §Responses ("a resultType of any value unrecognized by the client MUST be considered invalid"): unknown discriminators are `ProtocolError::InvalidResponse` instead of being treated as complete results. Closes spec-compliance driver gaps CF/GAP-CF-3, PRM/PR-2026-02, RES-G1, PAT/G3, BP-1 (all P1 except PAT/G3). Real-server e2e round-trips added; revert-and-fail recorded.

crates/turul-mcp-aws-lambda/src/builder.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ pub struct LambdaMcpServerBuilder {
9999
sampling: HashMap<String, Arc<dyn McpSampling>>,
100100

101101
/// Completion providers registered with the server
102-
completions: HashMap<String, Arc<dyn McpCompletion>>,
102+
completions: Vec<Arc<dyn McpCompletion>>,
103103

104104
/// Loggers registered with the server
105105
#[cfg(feature = "protocol-2025-11-25")]
@@ -176,7 +176,7 @@ impl LambdaMcpServerBuilder {
176176
handlers.insert("ping".to_string(), Arc::new(PingHandler));
177177
handlers.insert(
178178
"completion/complete".to_string(),
179-
Arc::new(CompletionHandler),
179+
Arc::new(CompletionHandler::new()),
180180
);
181181
handlers.insert(
182182
"resources/list".to_string(),
@@ -273,7 +273,7 @@ impl LambdaMcpServerBuilder {
273273
elicitations: HashMap::new(),
274274
#[cfg(feature = "protocol-2025-11-25")]
275275
sampling: HashMap::new(),
276-
completions: HashMap::new(),
276+
completions: Vec::new(),
277277
#[cfg(feature = "protocol-2025-11-25")]
278278
loggers: HashMap::new(),
279279
root_providers: HashMap::new(),
@@ -468,8 +468,7 @@ impl LambdaMcpServerBuilder {
468468

469469
/// Register a completion provider with the server
470470
pub fn completion_provider<C: McpCompletion + 'static>(mut self, completion: C) -> Self {
471-
let key = format!("completion_{}", self.completions.len());
472-
self.completions.insert(key, Arc::new(completion));
471+
self.completions.push(Arc::new(completion));
473472
self
474473
}
475474

@@ -596,7 +595,7 @@ impl LambdaMcpServerBuilder {
596595
pub fn with_completion(mut self) -> Self {
597596
use turul_mcp_protocol::initialize::CompletionsCapabilities;
598597
self.capabilities.completions = Some(CompletionsCapabilities::default());
599-
self.handler(CompletionHandler)
598+
self.handler(CompletionHandler::new())
600599
}
601600

602601
/// Add prompts support
@@ -1124,6 +1123,14 @@ impl LambdaMcpServerBuilder {
11241123

11251124
// Add RootsHandler if roots were configured (same pattern as MCP server)
11261125
let mut handlers = self.handlers;
1126+
1127+
// Route completion/complete through the registered providers.
1128+
if !self.completions.is_empty() {
1129+
handlers.insert(
1130+
"completion/complete".to_string(),
1131+
Arc::new(CompletionHandler::new().with_providers(self.completions.clone())),
1132+
);
1133+
}
11271134
if !self.roots.is_empty() {
11281135
let mut roots_handler = RootsHandler::new();
11291136
for root in &self.roots {

crates/turul-mcp-aws-lambda/src/server.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ pub struct LambdaMcpServer {
4747
#[cfg(feature = "protocol-2025-11-25")]
4848
sampling: HashMap<String, Arc<dyn McpSampling>>,
4949
/// Registered completion providers
50-
completions: HashMap<String, Arc<dyn McpCompletion>>,
50+
completions: Vec<Arc<dyn McpCompletion>>,
5151
/// Registered loggers
5252
#[cfg(feature = "protocol-2025-11-25")]
5353
loggers: HashMap<String, Arc<dyn McpLogger>>,
@@ -111,7 +111,7 @@ impl LambdaMcpServer {
111111
Arc<dyn McpElicitation>,
112112
>,
113113
#[cfg(feature = "protocol-2025-11-25")] sampling: HashMap<String, Arc<dyn McpSampling>>,
114-
completions: HashMap<String, Arc<dyn McpCompletion>>,
114+
completions: Vec<Arc<dyn McpCompletion>>,
115115
#[cfg(feature = "protocol-2025-11-25")] loggers: HashMap<String, Arc<dyn McpLogger>>,
116116
root_providers: HashMap<String, Arc<dyn McpRoot>>,
117117
notifications: HashMap<String, Arc<dyn McpNotification>>,

crates/turul-mcp-server/src/builder.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ pub struct McpServerBuilder {
5050
sampling: HashMap<String, Arc<dyn McpSampling>>,
5151

5252
/// Completion providers registered with the server
53-
completions: HashMap<String, Arc<dyn McpCompletion>>,
53+
completions: Vec<Arc<dyn McpCompletion>>,
5454

5555
/// Loggers registered with the server
5656
#[cfg(feature = "protocol-2025-11-25")]
@@ -137,7 +137,7 @@ impl McpServerBuilder {
137137
handlers.insert("ping".to_string(), Arc::new(PingHandler));
138138
handlers.insert(
139139
"completion/complete".to_string(),
140-
Arc::new(CompletionHandler),
140+
Arc::new(CompletionHandler::new()),
141141
);
142142
handlers.insert(
143143
"resources/list".to_string(),
@@ -241,7 +241,7 @@ impl McpServerBuilder {
241241
elicitations: HashMap::new(),
242242
#[cfg(feature = "protocol-2025-11-25")]
243243
sampling: HashMap::new(),
244-
completions: HashMap::new(),
244+
completions: Vec::new(),
245245
#[cfg(feature = "protocol-2025-11-25")]
246246
loggers: HashMap::new(),
247247
root_providers: HashMap::new(),
@@ -918,8 +918,7 @@ impl McpServerBuilder {
918918

919919
/// Register a completion provider with the server
920920
pub fn completion_provider<C: McpCompletion + 'static>(mut self, completion: C) -> Self {
921-
let key = format!("completion_{}", self.completions.len());
922-
self.completions.insert(key, Arc::new(completion));
921+
self.completions.push(Arc::new(completion));
923922
self
924923
}
925924

@@ -1084,7 +1083,7 @@ impl McpServerBuilder {
10841083
/// Add completion support
10851084
pub fn with_completion(mut self) -> Self {
10861085
self.capabilities.completions = Some(CompletionsCapabilities::default());
1087-
self.handler(CompletionHandler)
1086+
self.handler(CompletionHandler::new())
10881087
}
10891088

10901089
/// Add prompts support
@@ -1729,6 +1728,14 @@ impl McpServerBuilder {
17291728
handlers.insert("roots/list".to_string(), Arc::new(roots_handler));
17301729
}
17311730

1731+
// Route completion/complete through the registered providers.
1732+
if !self.completions.is_empty() {
1733+
handlers.insert(
1734+
"completion/complete".to_string(),
1735+
Arc::new(CompletionHandler::new().with_providers(self.completions.clone())),
1736+
);
1737+
}
1738+
17321739
// Add PromptsHandlers if prompts were configured
17331740
if !self.prompts.is_empty() {
17341741
let mut prompts_list_handler = PromptsListHandler::new();

crates/turul-mcp-server/src/handlers/mod.rs

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -234,20 +234,112 @@ impl McpHandler for PingHandler {
234234
}
235235
}
236236

237-
/// Completion handler for completion/complete endpoint
238-
pub struct CompletionHandler;
237+
/// Completion handler for the completion/complete endpoint.
238+
///
239+
/// Routes to registered [`McpCompletion`](crate::McpCompletion) providers:
240+
/// highest `priority()` first, first `can_handle()` wins. Responses honor the
241+
/// spec's 100-item cap on `completion.values` (oversized provider output is
242+
/// truncated with `total`/`hasMore` reflecting the cut). With no matching
243+
/// provider the result carries empty values.
244+
#[derive(Default)]
245+
pub struct CompletionHandler {
246+
providers: Vec<Arc<dyn crate::McpCompletion>>,
247+
}
248+
249+
impl CompletionHandler {
250+
pub fn new() -> Self {
251+
Self::default()
252+
}
253+
254+
pub fn with_providers(mut self, providers: Vec<Arc<dyn crate::McpCompletion>>) -> Self {
255+
self.providers = providers;
256+
// Stable sort: priority desc, insertion order as the tiebreak.
257+
self.providers
258+
.sort_by_key(|p| std::cmp::Reverse(p.priority()));
259+
self
260+
}
261+
}
262+
263+
/// Does a provider's declared reference target the request's reference?
264+
fn completion_reference_matches(
265+
declared: &turul_mcp_protocol::completion::CompletionReference,
266+
requested: &turul_mcp_protocol::completion::CompletionReference,
267+
) -> bool {
268+
use turul_mcp_protocol::completion::CompletionReference as Ref;
269+
match (declared, requested) {
270+
(Ref::Prompt(a), Ref::Prompt(b)) => a.name == b.name,
271+
(Ref::ResourceTemplate(a), Ref::ResourceTemplate(b)) => a.uri == b.uri,
272+
_ => false,
273+
}
274+
}
275+
276+
/// `completion.values` carries "Maximum 100 items" per the Completion spec.
277+
const COMPLETION_VALUES_CAP: usize = 100;
239278

240279
#[async_trait]
241280
impl McpHandler for CompletionHandler {
242-
async fn handle(&self, _params: Option<Value>) -> McpResult<Value> {
243-
use turul_mcp_protocol::completion::{CompleteResult, CompletionResult};
281+
async fn handle(&self, params: Option<Value>) -> McpResult<Value> {
282+
use turul_mcp_protocol::completion::{CompleteRequest, CompleteResult, CompletionResult};
283+
// The params struct is `CompleteRequestParams` on 2026-07-28 (required
284+
// `_meta`) and `CompleteParams` on the frozen 2025-11-25 snapshot.
285+
#[cfg(feature = "protocol-2025-11-25")]
286+
use turul_mcp_protocol::completion::CompleteParams as TypedCompleteParams;
287+
#[cfg(feature = "protocol-2026-07-28")]
288+
use turul_mcp_protocol::completion::CompleteRequestParams as TypedCompleteParams;
244289

245-
// Default implementation - can be overridden by users
246-
let values = vec!["example1".to_string(), "example2".to_string()];
290+
let params = params.ok_or_else(|| {
291+
McpError::InvalidParameters("completion/complete requires params".to_string())
292+
})?;
293+
let typed: TypedCompleteParams = serde_json::from_value(params)
294+
.map_err(|e| McpError::InvalidParameters(format!("invalid completion params: {e}")))?;
295+
// The untagged reference union accepts any `type` string — enforce
296+
// the schema's literals ("ref/prompt" / "ref/resource") here.
297+
let ref_type = match &typed.reference {
298+
turul_mcp_protocol::completion::CompletionReference::Prompt(p) => {
299+
(&p.ref_type, "ref/prompt")
300+
}
301+
turul_mcp_protocol::completion::CompletionReference::ResourceTemplate(r) => {
302+
(&r.ref_type, "ref/resource")
303+
}
304+
};
305+
if ref_type.0 != ref_type.1 {
306+
return Err(McpError::InvalidParameters(format!(
307+
"unknown completion reference type {:?} (expected \"ref/prompt\" or \"ref/resource\")",
308+
ref_type.0
309+
)));
310+
}
311+
let request = CompleteRequest {
312+
method: "completion/complete".to_string(),
313+
params: typed,
314+
};
247315

248-
let completion_result = CompletionResult::new(values);
249-
let response = CompleteResult::new(completion_result);
250-
serde_json::to_value(response).map_err(McpError::from)
316+
// Providers declaring the request's exact reference win; a provider
317+
// whose can_handle accepts the request is the fallback. Both passes
318+
// run in (priority desc, insertion) order, so routing is
319+
// deterministic.
320+
let provider = self
321+
.providers
322+
.iter()
323+
.find(|p| {
324+
completion_reference_matches(p.reference(), &request.params.reference)
325+
&& p.can_handle(&request)
326+
})
327+
.or_else(|| self.providers.iter().find(|p| p.can_handle(&request)));
328+
let result = match provider {
329+
Some(provider) => {
330+
crate::McpCompletion::validate_request(provider.as_ref(), &request).await?;
331+
let mut result = provider.complete(request).await?;
332+
let len = result.completion.values.len();
333+
if len > COMPLETION_VALUES_CAP {
334+
result.completion.values.truncate(COMPLETION_VALUES_CAP);
335+
result.completion.has_more = Some(true);
336+
result.completion.total.get_or_insert(len as u32);
337+
}
338+
result
339+
}
340+
None => CompleteResult::new(CompletionResult::new(Vec::new())),
341+
};
342+
serde_json::to_value(result).map_err(McpError::from)
251343
}
252344

253345
fn supported_methods(&self) -> Vec<String> {

0 commit comments

Comments
 (0)