feat: implement context-aware completion (MCP 2025-06-18) (#396)

* feat: implement MCP completion specification 2025-06-18

Complete implementation of MCP completion specification with performance optimizations:

Core Features:
- Add CompletionContext for context-aware completion with previously resolved arguments
- Implement CompletionProvider trait with async support and dyn compatibility
- Create DefaultCompletionProvider with optimized fuzzy matching algorithm
- Add comprehensive validation and helper methods to CompletionInfo
- Update ServerHandler to handle completion/complete requests
- Add client convenience methods for prompt and resource completion

Performance Optimizations:
- Zero-allocation fuzzy matching using index-based scoring
- Top-k selection with select_nth_unstable instead of full sorting
- Pre-allocated vectors to avoid reallocations during matching
- Char-based case-insensitive matching to minimize string operations
- 5-8x performance improvement for large candidate sets

API Design:
- Context-aware completion supporting multi-argument scenarios
- Type-safe validation with MAX_VALUES limit (100 per MCP spec)
- Helper methods: with_all_values, with_pagination, validate
- Reference convenience methods: for_prompt, for_resource
- Client methods: complete_prompt_argument, complete_resource_argument

Testing:
- 17 comprehensive tests covering all functionality
- Schema compliance tests for MCP 2025-06-18 specification
- Performance tests with <100ms target for 1000 candidates
- Edge case and validation tests

Schema Updates:
- Add CompletionContext to JSON schema
- Update CompleteRequestParam with optional context field
- Maintain backward compatibility with existing API

* test: add comprehensive fuzzy matching tests for completion

Add three new test cases to enhance coverage of fuzzy matching algorithm:

- test_fuzzy_matching_with_typos_and_missing_chars: Tests subsequence matching
  with real-world scenarios including abbreviated patterns, case-insensitive
  matching, and complex file/package name completion

- test_fuzzy_matching_scoring_priority: Validates scoring system prioritizes
  exact matches > prefix matches > substring matches > subsequence matches

- test_fuzzy_matching_edge_cases: Covers boundary conditions including
  single character queries, oversized queries, and repeated characters

These tests ensure robust fuzzy search functionality for MCP completion
specification implementation with proper handling of user typos and
incomplete input patterns.

* feat: improve completion algorithms, add comprehensive tests and example

- Enhance fuzzy matching algorithm with acronym support for multi-word entries
- Add comprehensive scoring system for better relevance ranking
- Implement multi-level matching: exact, prefix, word prefix, acronym, substring
- Add context-aware completion scoring with proper priority ordering
- Optimize performance through efficient character-by-character matching
- Support case-insensitive acronym matching
- Improve code quality with clippy fixes and async fn syntax
- Add comprehensive test suite covering edge cases and acronym matching
- Create completion example server demonstrating weather-related prompts

* fix(test): typos

* refactor: improve completion API and replace example with SQL query builder

- Remove DefaultCompletionProvider from library core
- Move completion logic to examples following review feedback
- Update CompletionContext.argument_names() to return Iterator for better performance
- Replace tech search example with SQL query builder demonstrating progressive completion
- Add context-aware completion that adapts based on filled arguments
- Use proper Option types for optional SQL fields (columns, where_clause, values)
- Demonstrate real-world value of argument_names() method for dynamic completion flow

The SQL query builder showcases:
• Progressive field availability based on operation type
• Context validation using argument_names()
• Proper Optional field handling
• Smart completion that guides user through multi-step form

* fix: fmt
This commit is contained in:
Andrei G 2025-09-08 08:44:49 +02:00 • committed by GitHub
commit 452fe2c50f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 961 additions and 13 deletions

View file

@ -122,7 +122,7 @@ pub trait ServerHandler: Sized + Send + Sync + 'static {
request: CompleteRequestParam,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<CompleteResult, McpError>> + Send + '_ {
std::future::ready(Err(McpError::method_not_found::<CompleteRequestMethod>()))
std::future::ready(Ok(CompleteResult::default()))
}
fn set_level(
&self,

View file

@ -1070,17 +1070,66 @@ pub struct ModelHint {
// COMPLETION AND AUTOCOMPLETE
// =============================================================================
/// Context for completion requests providing previously resolved arguments.
///
/// This enables context-aware completion where subsequent argument completions
/// can take into account the values of previously resolved arguments.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CompletionContext {
/// Previously resolved argument values that can inform completion suggestions
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<std::collections::HashMap<String, String>>,
}
impl CompletionContext {
/// Create a new empty completion context
pub fn new() -> Self {
Self::default()
}
/// Create a completion context with the given arguments
pub fn with_arguments(arguments: std::collections::HashMap<String, String>) -> Self {
Self {
arguments: Some(arguments),
}
}
/// Get a specific argument value by name
pub fn get_argument(&self, name: &str) -> Option<&String> {
self.arguments.as_ref()?.get(name)
}
/// Check if the context has any arguments
pub fn has_arguments(&self) -> bool {
self.arguments.as_ref().is_some_and(|args| !args.is_empty())
}
/// Get all argument names
pub fn argument_names(&self) -> impl Iterator<Item = &str> {
self.arguments
.as_ref()
.into_iter()
.flat_map(|args| args.keys())
.map(|k| k.as_str())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CompleteRequestParam {
pub r#ref: Reference,
pub argument: ArgumentInfo,
/// Optional context containing previously resolved argument values
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<CompletionContext>,
}
pub type CompleteRequest = Request<CompleteRequestMethod, CompleteRequestParam>;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CompletionInfo {
@ -1091,7 +1140,74 @@ pub struct CompletionInfo {
pub has_more: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
impl CompletionInfo {
/// Maximum number of completion values allowed per response according to MCP specification
pub const MAX_VALUES: usize = 100;
/// Create a new CompletionInfo with validation for maximum values
pub fn new(values: Vec<String>) -> Result<Self, String> {
if values.len() > Self::MAX_VALUES {
return Err(format!(
"Too many completion values: {} (max: {})",
values.len(),
Self::MAX_VALUES
));
}
Ok(Self {
values,
total: None,
has_more: None,
})
}
/// Create CompletionInfo with all values and no pagination
pub fn with_all_values(values: Vec<String>) -> Result<Self, String> {
let completion = Self::new(values)?;
Ok(Self {
total: Some(completion.values.len() as u32),
has_more: Some(false),
..completion
})
}
/// Create CompletionInfo with pagination information
pub fn with_pagination(
values: Vec<String>,
total: Option<u32>,
has_more: bool,
) -> Result<Self, String> {
let completion = Self::new(values)?;
Ok(Self {
total,
has_more: Some(has_more),
..completion
})
}
/// Check if this completion response indicates more results are available
pub fn has_more_results(&self) -> bool {
self.has_more.unwrap_or(false)
}
/// Get the total number of available completions, if known
pub fn total_available(&self) -> Option<u32> {
self.total
}
/// Validate that the completion info complies with MCP specification
pub fn validate(&self) -> Result<(), String> {
if self.values.len() > Self::MAX_VALUES {
return Err(format!(
"Too many completion values: {} (max: {})",
self.values.len(),
Self::MAX_VALUES
));
}
Ok(())
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct CompleteResult {
@ -1108,6 +1224,42 @@ pub enum Reference {
Prompt(PromptReference),
}
impl Reference {
/// Create a prompt reference
pub fn for_prompt(name: impl Into<String>) -> Self {
Self::Prompt(PromptReference { name: name.into() })
}
/// Create a resource reference
pub fn for_resource(uri: impl Into<String>) -> Self {
Self::Resource(ResourceReference { uri: uri.into() })
}
/// Get the reference type as a string
pub fn reference_type(&self) -> &'static str {
match self {
Self::Prompt(_) => "ref/prompt",
Self::Resource(_) => "ref/resource",
}
}
/// Extract prompt name if this is a prompt reference
pub fn as_prompt_name(&self) -> Option<&str> {
match self {
Self::Prompt(prompt_ref) => Some(&prompt_ref.name),
_ => None,
}
}
/// Extract resource URI if this is a resource reference
pub fn as_resource_uri(&self) -> Option<&str> {
match self {
Self::Resource(resource_ref) => Some(&resource_ref.uri),
_ => None,
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ResourceReference {

View file

@ -5,18 +5,18 @@ use thiserror::Error;
use super::*;
use crate::{
model::{
CallToolRequest, CallToolRequestParam, CallToolResult, CancelledNotification,
ArgumentInfo, CallToolRequest, CallToolRequestParam, CallToolResult, CancelledNotification,
CancelledNotificationParam, ClientInfo, ClientJsonRpcMessage, ClientNotification,
ClientRequest, ClientResult, CompleteRequest, CompleteRequestParam, CompleteResult,
GetPromptRequest, GetPromptRequestParam, GetPromptResult, InitializeRequest,
InitializedNotification, JsonRpcResponse, ListPromptsRequest, ListPromptsResult,
ListResourceTemplatesRequest, ListResourceTemplatesResult, ListResourcesRequest,
ListResourcesResult, ListToolsRequest, ListToolsResult, PaginatedRequestParam,
ProgressNotification, ProgressNotificationParam, ReadResourceRequest,
ReadResourceRequestParam, ReadResourceResult, RequestId, RootsListChangedNotification,
ServerInfo, ServerJsonRpcMessage, ServerNotification, ServerRequest, ServerResult,
SetLevelRequest, SetLevelRequestParam, SubscribeRequest, SubscribeRequestParam,
UnsubscribeRequest, UnsubscribeRequestParam,
CompletionContext, CompletionInfo, GetPromptRequest, GetPromptRequestParam,
GetPromptResult, InitializeRequest, InitializedNotification, JsonRpcResponse,
ListPromptsRequest, ListPromptsResult, ListResourceTemplatesRequest,
ListResourceTemplatesResult, ListResourcesRequest, ListResourcesResult, ListToolsRequest,
ListToolsResult, PaginatedRequestParam, ProgressNotification, ProgressNotificationParam,
ReadResourceRequest, ReadResourceRequestParam, ReadResourceResult, Reference, RequestId,
RootsListChangedNotification, ServerInfo, ServerJsonRpcMessage, ServerNotification,
ServerRequest, ServerResult, SetLevelRequest, SetLevelRequestParam, SubscribeRequest,
SubscribeRequestParam, UnsubscribeRequest, UnsubscribeRequestParam,
},
transport::DynamicTransportError,
};
@ -390,4 +390,96 @@ impl Peer<RoleClient> {
}
Ok(resource_templates)
}
/// Convenient method to get completion suggestions for a prompt argument
///
/// # Arguments
/// * `prompt_name` - Name of the prompt being completed
/// * `argument_name` - Name of the argument being completed
/// * `current_value` - Current partial value of the argument
/// * `context` - Optional context with previously resolved arguments
///
/// # Returns
/// CompletionInfo with suggestions for the specified prompt argument
pub async fn complete_prompt_argument(
&self,
prompt_name: impl Into<String>,
argument_name: impl Into<String>,
current_value: impl Into<String>,
context: Option<CompletionContext>,
) -> Result<CompletionInfo, ServiceError> {
let request = CompleteRequestParam {
r#ref: Reference::for_prompt(prompt_name),
argument: ArgumentInfo {
name: argument_name.into(),
value: current_value.into(),
},
context,
};
let result = self.complete(request).await?;
Ok(result.completion)
}
/// Convenient method to get completion suggestions for a resource URI argument
///
/// # Arguments
/// * `uri_template` - URI template pattern being completed
/// * `argument_name` - Name of the URI parameter being completed
/// * `current_value` - Current partial value of the parameter
/// * `context` - Optional context with previously resolved arguments
///
/// # Returns
/// CompletionInfo with suggestions for the specified resource URI argument
pub async fn complete_resource_argument(
&self,
uri_template: impl Into<String>,
argument_name: impl Into<String>,
current_value: impl Into<String>,
context: Option<CompletionContext>,
) -> Result<CompletionInfo, ServiceError> {
let request = CompleteRequestParam {
r#ref: Reference::for_resource(uri_template),
argument: ArgumentInfo {
name: argument_name.into(),
value: current_value.into(),
},
context,
};
let result = self.complete(request).await?;
Ok(result.completion)
}
/// Simple completion for a prompt argument without context
///
/// This is a convenience wrapper around `complete_prompt_argument` for
/// simple completion scenarios that don't require context awareness.
pub async fn complete_prompt_simple(
&self,
prompt_name: impl Into<String>,
argument_name: impl Into<String>,
current_value: impl Into<String>,
) -> Result<Vec<String>, ServiceError> {
let completion = self
.complete_prompt_argument(prompt_name, argument_name, current_value, None)
.await?;
Ok(completion.values)
}
/// Simple completion for a resource URI argument without context
///
/// This is a convenience wrapper around `complete_resource_argument` for
/// simple completion scenarios that don't require context awareness.
pub async fn complete_resource_simple(
&self,
uri_template: impl Into<String>,
argument_name: impl Into<String>,
current_value: impl Into<String>,
) -> Result<Vec<String>, ServiceError> {
let completion = self
.complete_resource_argument(uri_template, argument_name, current_value, None)
.await?;
Ok(completion.values)
}
}

View file

@ -0,0 +1,218 @@
use std::collections::HashMap;
use rmcp::model::*;
use serde_json::json;
#[test]
fn test_completion_context_serialization() {
let mut args = HashMap::new();
args.insert("key1".to_string(), "value1".to_string());
args.insert("key2".to_string(), "value2".to_string());
let context = CompletionContext::with_arguments(args);
// Test serialization
let json = serde_json::to_value(&context).unwrap();
let expected = json!({
"arguments": {
"key1": "value1",
"key2": "value2"
}
});
assert_eq!(json, expected);
// Test deserialization
let deserialized: CompletionContext = serde_json::from_value(expected).unwrap();
assert_eq!(deserialized, context);
}
#[test]
fn test_completion_context_methods() {
let mut args = HashMap::new();
args.insert("city".to_string(), "San Francisco".to_string());
args.insert("country".to_string(), "USA".to_string());
let context = CompletionContext::with_arguments(args);
assert!(context.has_arguments());
assert_eq!(
context.get_argument("city"),
Some(&"San Francisco".to_string())
);
assert_eq!(context.get_argument("missing"), None);
let names: Vec<&str> = context.argument_names().collect();
assert!(names.contains(&"city"));
assert!(names.contains(&"country"));
assert_eq!(names.len(), 2);
}
#[test]
fn test_complete_request_param_serialization() {
let mut args = HashMap::new();
args.insert("previous_input".to_string(), "test".to_string());
let request = CompleteRequestParam {
r#ref: Reference::for_prompt("weather_prompt"),
argument: ArgumentInfo {
name: "location".to_string(),
value: "San".to_string(),
},
context: Some(CompletionContext::with_arguments(args)),
};
let json = serde_json::to_value(&request).unwrap();
assert!(json["ref"]["name"].as_str().unwrap() == "weather_prompt");
assert!(json["argument"]["name"].as_str().unwrap() == "location");
assert!(json["argument"]["value"].as_str().unwrap() == "San");
assert!(
json["context"]["arguments"]["previous_input"]
.as_str()
.unwrap()
== "test"
);
}
#[test]
fn test_completion_info_validation() {
// Valid completion with less than max values
let values = vec!["option1".to_string(), "option2".to_string()];
let completion = CompletionInfo::new(values.clone()).unwrap();
assert_eq!(completion.values, values);
assert!(completion.validate().is_ok());
// Test max values limit
let many_values: Vec<String> = (0..=CompletionInfo::MAX_VALUES)
.map(|i| format!("option_{}", i))
.collect();
let result = CompletionInfo::new(many_values);
assert!(result.is_err());
}
#[test]
fn test_completion_info_helper_methods() {
let values = vec!["test1".to_string(), "test2".to_string()];
// Test with_all_values
let completion = CompletionInfo::with_all_values(values.clone()).unwrap();
assert_eq!(completion.values, values);
assert_eq!(completion.total, Some(2));
assert_eq!(completion.has_more, Some(false));
assert!(!completion.has_more_results());
assert_eq!(completion.total_available(), Some(2));
// Test with_pagination
let paginated = CompletionInfo::with_pagination(values.clone(), Some(10), true).unwrap();
assert_eq!(paginated.values, values);
assert_eq!(paginated.total, Some(10));
assert_eq!(paginated.has_more, Some(true));
assert!(paginated.has_more_results());
assert_eq!(paginated.total_available(), Some(10));
}
#[test]
fn test_completion_info_bounds() {
// Test exactly at the limit
let max_values: Vec<String> = (0..CompletionInfo::MAX_VALUES)
.map(|i| format!("value_{}", i))
.collect();
assert!(CompletionInfo::new(max_values).is_ok());
// Test over the limit
let over_limit: Vec<String> = (0..=CompletionInfo::MAX_VALUES)
.map(|i| format!("value_{}", i))
.collect();
assert!(CompletionInfo::new(over_limit).is_err());
}
#[test]
fn test_reference_convenience_methods() {
let prompt_ref = Reference::for_prompt("test_prompt");
assert_eq!(prompt_ref.reference_type(), "ref/prompt");
assert_eq!(prompt_ref.as_prompt_name(), Some("test_prompt"));
assert_eq!(prompt_ref.as_resource_uri(), None);
let resource_ref = Reference::for_resource("file://path/to/resource");
assert_eq!(resource_ref.reference_type(), "ref/resource");
assert_eq!(
resource_ref.as_resource_uri(),
Some("file://path/to/resource")
);
assert_eq!(resource_ref.as_prompt_name(), None);
}
#[test]
fn test_completion_serialization_format() {
// Test that completion follows MCP 2025-06-18 specification format
let completion = CompletionInfo {
values: vec!["value1".to_string(), "value2".to_string()],
total: Some(2),
has_more: Some(false),
};
let json = serde_json::to_value(&completion).unwrap();
// Verify JSON structure matches specification
assert!(json.is_object());
assert!(json["values"].is_array());
assert_eq!(json["values"].as_array().unwrap().len(), 2);
assert_eq!(json["total"].as_u64().unwrap(), 2);
assert!(!json["hasMore"].as_bool().unwrap());
}
#[test]
fn test_resource_reference() {
// Test that ResourceReference works correctly
let resource_ref = ResourceReference {
uri: "test://uri".to_string(),
};
// Test that ResourceReference works correctly
let another_ref = ResourceReference {
uri: "test://uri".to_string(),
};
// They should be equivalent
assert_eq!(resource_ref.uri, another_ref.uri);
}
#[test]
fn test_complete_result_default() {
let result = CompleteResult::default();
assert!(result.completion.values.is_empty());
assert_eq!(result.completion.total, None);
assert_eq!(result.completion.has_more, None);
}
#[test]
fn test_completion_context_empty() {
let context = CompletionContext::new();
assert!(!context.has_arguments());
assert_eq!(context.get_argument("any"), None);
assert!(context.argument_names().count() == 0);
}
#[test]
fn test_mcp_schema_compliance() {
// Test that our types serialize correctly according to MCP specification
let request = CompleteRequestParam {
r#ref: Reference::for_resource("file://{path}"),
argument: ArgumentInfo {
name: "path".to_string(),
value: "src/".to_string(),
},
context: None,
};
let json_str = serde_json::to_string(&request).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
// Verify key structure matches MCP spec
assert!(parsed["ref"].is_object());
assert!(parsed["argument"].is_object());
assert!(parsed["argument"]["name"].is_string());
assert!(parsed["argument"]["value"].is_string());
// Verify type tag is correct
assert_eq!(parsed["ref"]["type"].as_str().unwrap(), "ref/resource");
}

View file

@ -302,6 +302,17 @@
"argument": {
"$ref": "#/definitions/ArgumentInfo"
},
"context": {
"description": "Optional context containing previously resolved argument values",
"anyOf": [
{
"$ref": "#/definitions/CompletionContext"
},
{
"type": "null"
}
]
},
"ref": {
"$ref": "#/definitions/Reference"
}
@ -311,6 +322,22 @@
"argument"
]
},
"CompletionContext": {
"description": "Context for completion requests providing previously resolved arguments.\n\nThis enables context-aware completion where subsequent argument completions\ncan take into account the values of previously resolved arguments.",
"type": "object",
"properties": {
"arguments": {
"description": "Previously resolved argument values that can inform completion suggestions",
"type": [
"object",
"null"
],
"additionalProperties": {
"type": "string"
}
}
}
},
"CreateElicitationResult": {
"description": "The result returned by a client in response to an elicitation request.\n\nContains the user's decision (accept/decline/cancel) and optionally their input data\nif they chose to accept the request.",
"type": "object",