feat!: align model types with MCP 2025-11-25 spec (#927)

* feat!: align model types with MCP 2025-11-25 spec

* fix: complete 2025-11-25 model spec conformance
This commit is contained in:
Dale Seo 2026-06-24 20:41:13 -04:00 • committed by GitHub
commit 77932141e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
56 changed files with 4321 additions and 3886 deletions

View file

@ -45,48 +45,46 @@ struct ElicitationDefaultsClientHandler;
impl ClientHandler for ElicitationDefaultsClientHandler {
fn get_info(&self) -> ClientInfo {
let mut info = ClientInfo::default();
info.capabilities.elicitation = Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: Some(true),
}),
url: None,
});
info.capabilities.elicitation = Some(
ElicitationCapability::new()
.with_form(FormElicitationCapability::new().with_schema_validation(true)),
);
info
}
async fn create_elicitation(
&self,
request: CreateElicitationRequestParams,
request: ElicitRequestParams,
_cx: RequestContext<RoleClient>,
) -> Result<CreateElicitationResult, ErrorData> {
) -> Result<ElicitResult, ErrorData> {
let content = match &request {
CreateElicitationRequestParams::FormElicitationParams {
ElicitRequestParams::FormElicitationParams {
requested_schema, ..
} => {
let mut defaults = serde_json::Map::new();
for (name, prop) in &requested_schema.properties {
match prop {
PrimitiveSchema::String(s) => {
PrimitiveSchemaDefinition::String(s) => {
if let Some(d) = &s.default {
defaults.insert(name.clone(), Value::String(d.clone()));
}
}
PrimitiveSchema::Number(n) => {
PrimitiveSchemaDefinition::Number(n) => {
if let Some(d) = n.default {
defaults.insert(name.clone(), json!(d));
}
}
PrimitiveSchema::Integer(i) => {
PrimitiveSchemaDefinition::Integer(i) => {
if let Some(d) = i.default {
defaults.insert(name.clone(), json!(d));
}
}
PrimitiveSchema::Boolean(b) => {
PrimitiveSchemaDefinition::Boolean(b) => {
if let Some(d) = b.default {
defaults.insert(name.clone(), Value::Bool(d));
}
}
PrimitiveSchema::Enum(e) => {
PrimitiveSchemaDefinition::Enum(e) => {
let val = match e {
EnumSchema::Single(SingleSelectEnumSchema::Untitled(u)) => {
u.default.as_ref().map(|d| Value::String(d.clone()))
@ -109,22 +107,24 @@ impl ClientHandler for ElicitationDefaultsClientHandler {
})
}
EnumSchema::Legacy(_) => None,
_ => None,
};
if let Some(v) = val {
defaults.insert(name.clone(), v);
}
}
_ => {}
}
}
Some(Value::Object(defaults))
}
_ => Some(json!({})),
};
Ok(CreateElicitationResult {
action: ElicitationAction::Accept,
content,
meta: None,
})
let mut result = ElicitResult::new(ElicitationAction::Accept);
if let Some(c) = content {
result = result.with_content(c);
}
Ok(result)
}
}
@ -134,12 +134,10 @@ struct FullClientHandler;
impl ClientHandler for FullClientHandler {
fn get_info(&self) -> ClientInfo {
let mut info = ClientInfo::default();
info.capabilities.elicitation = Some(ElicitationCapability {
form: Some(FormElicitationCapability {
schema_validation: Some(true),
}),
url: None,
});
info.capabilities.elicitation = Some(
ElicitationCapability::new()
.with_form(FormElicitationCapability::new().with_schema_validation(true)),
);
info
}
@ -158,7 +156,7 @@ impl ClientHandler for FullClientHandler {
Ok(CreateMessageResult::new(
SamplingMessage::new(
Role::Assistant,
SamplingMessageContent::text(format!(
SamplingMessageContentBlock::text(format!(
"This is a mock LLM response to: {}",
prompt_text
)),
@ -170,14 +168,11 @@ impl ClientHandler for FullClientHandler {
async fn create_elicitation(
&self,
_request: CreateElicitationRequestParams,
_request: ElicitRequestParams,
_cx: RequestContext<RoleClient>,
) -> Result<CreateElicitationResult, ErrorData> {
Ok(CreateElicitationResult {
action: ElicitationAction::Accept,
content: Some(json!({"username": "testuser", "email": "test@example.com"})),
meta: None,
})
) -> Result<ElicitResult, ErrorData> {
Ok(ElicitResult::new(ElicitationAction::Accept)
.with_content(json!({"username": "testuser", "email": "test@example.com"})))
}
}

View file

@ -217,25 +217,21 @@ impl ServerHandler for ConformanceServer {
) -> Result<CallToolResult, ErrorData> {
let args = request.arguments.unwrap_or_default();
match request.name.as_ref() {
"test_simple_text" => Ok(CallToolResult::success(vec![Content::text(
"test_simple_text" => Ok(CallToolResult::success(vec![ContentBlock::text(
"This is a simple text response for testing.",
)])),
"test_image_content" => Ok(CallToolResult::success(vec![Content::image(
"test_image_content" => Ok(CallToolResult::success(vec![ContentBlock::image(
TEST_IMAGE_DATA,
"image/png",
)])),
"test_audio_content" => {
let audio = RawContent::Audio(RawAudioContent {
data: TEST_AUDIO_DATA.into(),
mime_type: "audio/wav".into(),
})
.no_annotation();
let audio = ContentBlock::Audio(AudioContent::new(TEST_AUDIO_DATA, "audio/wav"));
Ok(CallToolResult::success(vec![audio]))
}
"test_embedded_resource" => Ok(CallToolResult::success(vec![Content::resource(
"test_embedded_resource" => Ok(CallToolResult::success(vec![ContentBlock::resource(
ResourceContents::TextResourceContents {
uri: "test://embedded-resource".into(),
mime_type: Some("text/plain".into()),
@ -245,9 +241,9 @@ impl ServerHandler for ConformanceServer {
)])),
"test_multiple_content_types" => Ok(CallToolResult::success(vec![
Content::text("Multiple content types test:"),
Content::image(TEST_IMAGE_DATA, "image/png"),
Content::resource(ResourceContents::TextResourceContents {
ContentBlock::text("Multiple content types test:"),
ContentBlock::image(TEST_IMAGE_DATA, "image/png"),
ContentBlock::resource(ResourceContents::TextResourceContents {
uri: "test://mixed-content-resource".into(),
mime_type: Some("application/json".into()),
text: r#"{"test":"data","value":123}"#.into(),
@ -263,21 +259,20 @@ impl ServerHandler for ConformanceServer {
] {
let _ = cx
.peer
.notify_logging_message(LoggingMessageNotificationParam {
level: LoggingLevel::Info,
logger: Some("conformance-server".into()),
data: json!(msg),
})
.notify_logging_message(
LoggingMessageNotificationParam::new(LoggingLevel::Info, json!(msg))
.with_logger("conformance-server"),
)
.await;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
Ok(CallToolResult::success(vec![Content::text(
Ok(CallToolResult::success(vec![ContentBlock::text(
"Logging test completed",
)]))
}
"test_error_handling" => Ok(CallToolResult::error(vec![Content::text(
"test_error_handling" => Ok(CallToolResult::error(vec![ContentBlock::text(
"This tool intentionally returns an error for testing",
)])),
@ -290,18 +285,17 @@ impl ServerHandler for ConformanceServer {
if let Some(token) = &progress_token {
let _ = cx
.peer
.notify_progress(ProgressNotificationParam {
progress_token: token.clone(),
progress,
total: Some(100.0),
message: Some(message.into()),
})
.notify_progress(
ProgressNotificationParam::new(token.clone(), progress)
.with_total(100.0)
.with_message(message),
)
.await;
}
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
}
Ok(CallToolResult::success(vec![Content::text(
Ok(CallToolResult::success(vec![ContentBlock::text(
"Progress test completed",
)]))
}
@ -328,12 +322,12 @@ impl ServerHandler for ConformanceServer {
.and_then(|c| c.as_text())
.map(|t| t.text.clone())
.unwrap_or_else(|| "No text response".into());
Ok(CallToolResult::success(vec![Content::text(format!(
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"LLM response: {}",
text
))]))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"Sampling error: {}",
e
))])),
@ -365,23 +359,24 @@ impl ServerHandler for ConformanceServer {
match cx
.peer
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
.create_elicitation(ElicitRequestParams::FormElicitationParams {
meta: None,
message: message.into(),
requested_schema: schema,
})
.await
{
Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!(
Ok(result) => Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"User response: action={}, content={:?}",
match result.action {
ElicitationAction::Accept => "accept",
ElicitationAction::Decline => "decline",
ElicitationAction::Cancel => "cancel",
_ => "unknown",
},
result.content
))])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"Elicitation error: {}",
e
))])),
@ -425,23 +420,24 @@ impl ServerHandler for ConformanceServer {
match cx
.peer
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
.create_elicitation(ElicitRequestParams::FormElicitationParams {
meta: None,
message: "Please provide values (all have defaults)".into(),
requested_schema: schema,
})
.await
{
Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!(
Ok(result) => Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"Elicitation completed: action={}, content={:?}",
match result.action {
ElicitationAction::Accept => "accept",
ElicitationAction::Decline => "decline",
ElicitationAction::Cancel => "cancel",
_ => "unknown",
},
result.content
))])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"Elicitation error: {}",
e
))])),
@ -493,22 +489,23 @@ impl ServerHandler for ConformanceServer {
match cx
.peer
.create_elicitation(CreateElicitationRequestParams::FormElicitationParams {
.create_elicitation(ElicitRequestParams::FormElicitationParams {
meta: None,
message: "Test enum schema improvements".into(),
requested_schema: schema,
})
.await
{
Ok(result) => Ok(CallToolResult::success(vec![Content::text(format!(
Ok(result) => Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"Enum elicitation completed: action={}",
match result.action {
ElicitationAction::Accept => "accept",
ElicitationAction::Decline => "decline",
ElicitationAction::Cancel => "cancel",
_ => "unknown",
}
))])),
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"Elicitation error: {}",
e
))])),
@ -517,7 +514,7 @@ impl ServerHandler for ConformanceServer {
"json_schema_2020_12_tool" => {
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("world");
Ok(CallToolResult::success(vec![Content::text(format!(
Ok(CallToolResult::success(vec![ContentBlock::text(format!(
"Hello, {}!",
name
))]))
@ -525,7 +522,7 @@ impl ServerHandler for ConformanceServer {
"test_reconnection" => {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
Ok(CallToolResult::success(vec![Content::text(
Ok(CallToolResult::success(vec![ContentBlock::text(
"Reconnection test completed",
)]))
}
@ -545,28 +542,12 @@ impl ServerHandler for ConformanceServer {
Ok(ListResourcesResult {
meta: None,
resources: vec![
RawResource {
uri: "test://static-text".into(),
name: "Static Text Resource".into(),
title: None,
description: Some("A static text resource for testing".into()),
mime_type: Some("text/plain".into()),
size: None,
icons: None,
meta: None,
}
.no_annotation(),
RawResource {
uri: "test://static-binary".into(),
name: "Static Binary Resource".into(),
title: None,
description: Some("A static binary/blob resource for testing".into()),
mime_type: Some("image/png".into()),
size: None,
icons: None,
meta: None,
}
.no_annotation(),
Resource::new("test://static-text", "Static Text Resource")
.with_description("A static text resource for testing")
.with_mime_type("text/plain"),
Resource::new("test://static-binary", "Static Binary Resource")
.with_description("A static binary/blob resource for testing")
.with_mime_type("image/png"),
],
next_cursor: None,
})
@ -630,15 +611,9 @@ impl ServerHandler for ConformanceServer {
Ok(ListResourceTemplatesResult {
meta: None,
resource_templates: vec![
RawResourceTemplate {
uri_template: "test://template/{id}/data".into(),
name: "Dynamic Resource".into(),
title: None,
description: Some("A dynamic resource with parameter substitution".into()),
mime_type: Some("application/json".into()),
icons: None,
}
.no_annotation(),
ResourceTemplate::new("test://template/{id}/data", "Dynamic Resource")
.with_description("A dynamic resource with parameter substitution")
.with_mime_type("application/json"),
],
next_cursor: None,
})
@ -711,7 +686,7 @@ impl ServerHandler for ConformanceServer {
) -> Result<GetPromptResult, ErrorData> {
match request.name.as_str() {
"test_simple_prompt" => Ok(GetPromptResult::new(vec![PromptMessage::new_text(
PromptMessageRole::User,
Role::User,
"This is a simple test prompt.",
)])
.with_description("A simple test prompt")),
@ -723,15 +698,15 @@ impl ServerHandler for ConformanceServer {
.and_then(|v| v.as_str())
.unwrap_or("friendly");
Ok(GetPromptResult::new(vec![PromptMessage::new_text(
PromptMessageRole::User,
Role::User,
format!("Please greet {} in a {} style.", name, style),
)])
.with_description("A prompt with arguments"))
}
"test_prompt_with_embedded_resource" => Ok(GetPromptResult::new(vec![
PromptMessage::new_text(PromptMessageRole::User, "Here is a resource:"),
PromptMessage::new_text(Role::User, "Here is a resource:"),
PromptMessage::new_resource(
PromptMessageRole::User,
Role::User,
"test://static-text".into(),
Some("text/plain".into()),
Some("Resource content for prompt".into()),
@ -742,19 +717,10 @@ impl ServerHandler for ConformanceServer {
])
.with_description("A prompt with an embedded resource")),
"test_prompt_with_image" => {
let image_content = RawImageContent {
data: TEST_IMAGE_DATA.into(),
mime_type: "image/png".into(),
meta: None,
};
let image_content = ImageContent::new(TEST_IMAGE_DATA, "image/png");
Ok(GetPromptResult::new(vec![
PromptMessage::new_text(PromptMessageRole::User, "Here is an image:"),
PromptMessage::new(
PromptMessageRole::User,
PromptMessageContent::Image {
image: image_content.no_annotation(),
},
),
PromptMessage::new_text(Role::User, "Here is an image:"),
PromptMessage::new(Role::User, ContentBlock::Image(image_content)),
])
.with_description("A prompt with an image"))
}
@ -787,6 +753,7 @@ impl ServerHandler for ConformanceServer {
vec![prompt_ref.name.clone()]
}
}
_ => vec![],
};
Ok(CompleteResult::new(
CompletionInfo::new(values).map_err(|e| ErrorData::internal_error(e, None))?,

View file

@ -111,7 +111,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
let get_info_fn = quote! {
async fn get_task_info(
&self,
request: rmcp::model::GetTaskInfoParams,
request: rmcp::model::GetTaskParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::GetTaskResult, McpError> {
use rmcp::task_manager::current_timestamp;
@ -145,7 +145,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
if let Some(ttl) = completed_result.descriptor.ttl {
task = task.with_ttl(ttl);
}
return Ok(rmcp::model::GetTaskResult { meta: None, task });
return Ok(rmcp::model::GetTaskResult::new(task));
}
// If not completed, check running
@ -158,7 +158,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
timestamp.clone(),
timestamp,
);
return Ok(rmcp::model::GetTaskResult { meta: None, task });
return Ok(rmcp::model::GetTaskResult::new(task));
}
Err(McpError::resource_not_found(format!("task not found: {}", task_id), None))
@ -171,7 +171,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
let get_result_fn = quote! {
async fn get_task_result(
&self,
request: rmcp::model::GetTaskResultParams,
request: rmcp::model::GetTaskPayloadParams,
_context: rmcp::service::RequestContext<rmcp::RoleServer>,
) -> Result<rmcp::model::GetTaskPayloadResult, McpError> {
use std::time::Duration;
@ -242,7 +242,7 @@ pub fn task_handler(attr: TokenStream, input: TokenStream) -> syn::Result<TokenS
timestamp.clone(),
timestamp,
);
return Ok(rmcp::model::CancelTaskResult { meta: None, task });
return Ok(rmcp::model::CancelTaskResult::new(task));
}
// If already completed, signal it's not cancellable

View file

@ -26,10 +26,10 @@ impl<H: ClientHandler> Service<RoleClient> for H {
.list_roots(context)
.await
.map(ClientResult::ListRootsResult),
ServerRequest::CreateElicitationRequest(request) => self
ServerRequest::ElicitRequest(request) => self
.create_elicitation(request.params, context)
.await
.map(ClientResult::CreateElicitationResult),
.map(ClientResult::ElicitResult),
ServerRequest::CustomRequest(request) => self
.on_custom_request(request, context)
.await
@ -64,10 +64,13 @@ impl<H: ClientHandler> Service<RoleClient> for H {
ServerNotification::PromptListChangedNotification(_notification_no_param) => {
self.on_prompt_list_changed(context).await
}
ServerNotification::ElicitationCompletionNotification(notification) => {
ServerNotification::ElicitationCompleteNotification(notification) => {
self.on_url_elicitation_notification_complete(notification.params, context)
.await
}
ServerNotification::TaskStatusNotification(notification) => {
self.on_task_status(notification.params, context).await
}
ServerNotification::CustomNotification(notification) => {
self.on_custom_notification(notification, context).await
}
@ -125,7 +128,7 @@ pub trait ClientHandler: Sized + Send + Sync + 'static {
///
/// # Example
/// ```rust,ignore
/// use rmcp::model::CreateElicitationRequestParam;
/// use rmcp::model::ElicitRequestParams;
/// use rmcp::{
/// model::ErrorData as McpError,
/// model::*,
@ -136,23 +139,23 @@ pub trait ClientHandler: Sized + Send + Sync + 'static {
/// impl ClientHandler for MyClient {
/// async fn create_elicitation(
/// &self,
/// request: CreateElicitationRequestParam,
/// request: ElicitRequestParams,
/// context: RequestContext<RoleClient>,
/// ) -> Result<CreateElicitationResult, McpError> {
/// ) -> Result<ElicitResult, McpError> {
/// match request {
/// CreateElicitationRequestParam::FormElicitationParam {meta, message, requested_schema,} => {
/// ElicitRequestParams::FormElicitationParam {meta, message, requested_schema,} => {
/// // Display message to user and collect input according to requested_schema
/// let user_input = get_user_input(message, requested_schema).await?;
/// Ok(CreateElicitationResult {
/// Ok(ElicitResult {
/// action: ElicitationAction::Accept,
/// content: Some(user_input),
/// meta: None,
/// })
/// }
/// CreateElicitationRequestParam::UrlElicitationParam {meta, message, url, elicitation_id,} => {
/// ElicitRequestParams::UrlElicitationParam {meta, message, url, elicitation_id,} => {
/// // Open URL in browser for user to complete elicitation
/// open_url_in_browser(url).await?;
/// Ok(CreateElicitationResult {
/// Ok(ElicitResult {
/// action: ElicitationAction::Accept,
/// content: None,
/// meta: None,
@ -164,13 +167,12 @@ pub trait ClientHandler: Sized + Send + Sync + 'static {
/// ```
fn create_elicitation(
&self,
request: CreateElicitationRequestParams,
request: ElicitRequestParams,
context: RequestContext<RoleClient>,
) -> impl Future<Output = Result<CreateElicitationResult, McpError>> + MaybeSendFuture + '_
{
) -> impl Future<Output = Result<ElicitResult, McpError>> + MaybeSendFuture + '_ {
// Default implementation declines all requests - real clients should override this
let _ = (request, context);
std::future::ready(Ok(CreateElicitationResult {
std::future::ready(Ok(ElicitResult {
action: ElicitationAction::Decline,
content: None,
meta: None,
@ -245,6 +247,13 @@ pub trait ClientHandler: Sized + Send + Sync + 'static {
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
fn on_task_status(
&self,
params: TaskStatusNotificationParam,
context: NotificationContext<RoleClient>,
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
fn on_custom_notification(
&self,
notification: CustomNotification,
@ -283,22 +292,24 @@ macro_rules! impl_client_handler_for_wrapper {
&self,
params: CreateMessageRequestParams,
context: RequestContext<RoleClient>,
) -> impl Future<Output = Result<CreateMessageResult, McpError>> + MaybeSendFuture + '_ {
) -> impl Future<Output = Result<CreateMessageResult, McpError>> + MaybeSendFuture + '_
{
(**self).create_message(params, context)
}
fn list_roots(
&self,
context: RequestContext<RoleClient>,
) -> impl Future<Output = Result<ListRootsResult, McpError>> + MaybeSendFuture + '_ {
) -> impl Future<Output = Result<ListRootsResult, McpError>> + MaybeSendFuture + '_
{
(**self).list_roots(context)
}
fn create_elicitation(
&self,
request: CreateElicitationRequestParams,
request: ElicitRequestParams,
context: RequestContext<RoleClient>,
) -> impl Future<Output = Result<CreateElicitationResult, McpError>> + MaybeSendFuture + '_ {
) -> impl Future<Output = Result<ElicitResult, McpError>> + MaybeSendFuture + '_ {
(**self).create_elicitation(request, context)
}
@ -363,6 +374,14 @@ macro_rules! impl_client_handler_for_wrapper {
(**self).on_prompt_list_changed(context)
}
fn on_task_status(
&self,
params: TaskStatusNotificationParam,
context: NotificationContext<RoleClient>,
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
(**self).on_task_status(params, context)
}
fn on_custom_notification(
&self,
notification: CustomNotification,

View file

@ -117,11 +117,11 @@ impl<H: ServerHandler> Service<RoleServer> for H {
.list_tasks(request.params, context)
.await
.map(ServerResult::ListTasksResult),
ClientRequest::GetTaskInfoRequest(request) => self
ClientRequest::GetTaskRequest(request) => self
.get_task_info(request.params, context)
.await
.map(ServerResult::GetTaskResult),
ClientRequest::GetTaskResultRequest(request) => self
ClientRequest::GetTaskPayloadRequest(request) => self
.get_task_result(request.params, context)
.await
.map(ServerResult::GetTaskPayloadResult),
@ -161,6 +161,9 @@ impl<H: ServerHandler> Service<RoleServer> for H {
ClientNotification::RootsListChangedNotification(_notification) => {
self.on_roots_list_changed(context).await
}
ClientNotification::TaskStatusNotification(notification) => {
self.on_task_status(notification.params, context).await
}
ClientNotification::CustomNotification(notification) => {
self.on_custom_notification(notification, context).await
}
@ -359,6 +362,13 @@ macro_rules! server_handler_methods {
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
fn on_task_status(
&self,
params: TaskStatusNotificationParam,
context: NotificationContext<RoleServer>,
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
std::future::ready(())
}
fn on_custom_notification(
&self,
notification: CustomNotification,
@ -382,20 +392,20 @@ macro_rules! server_handler_methods {
fn get_task_info(
&self,
request: GetTaskInfoParams,
request: GetTaskParams,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<GetTaskResult, McpError>> + MaybeSendFuture + '_ {
let _ = (request, context);
std::future::ready(Err(McpError::method_not_found::<GetTaskInfoMethod>()))
std::future::ready(Err(McpError::method_not_found::<GetTaskMethod>()))
}
fn get_task_result(
&self,
request: GetTaskResultParams,
request: GetTaskPayloadParams,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<GetTaskPayloadResult, McpError>> + MaybeSendFuture + '_ {
let _ = (request, context);
std::future::ready(Err(McpError::method_not_found::<GetTaskResultMethod>()))
std::future::ready(Err(McpError::method_not_found::<GetTaskPayloadMethod>()))
}
fn cancel_task(
@ -578,6 +588,14 @@ macro_rules! impl_server_handler_for_wrapper {
(**self).on_roots_list_changed(context)
}
fn on_task_status(
&self,
params: TaskStatusNotificationParam,
context: NotificationContext<RoleServer>,
) -> impl Future<Output = ()> + MaybeSendFuture + '_ {
(**self).on_task_status(params, context)
}
fn on_custom_notification(
&self,
notification: CustomNotification,
@ -600,7 +618,7 @@ macro_rules! impl_server_handler_for_wrapper {
fn get_task_info(
&self,
request: GetTaskInfoParams,
request: GetTaskParams,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<GetTaskResult, McpError>> + MaybeSendFuture + '_ {
(**self).get_task_info(request, context)
@ -608,7 +626,7 @@ macro_rules! impl_server_handler_for_wrapper {
fn get_task_result(
&self,
request: GetTaskResultParams,
request: GetTaskPayloadParams,
context: RequestContext<RoleServer>,
) -> impl Future<Output = Result<GetTaskPayloadResult, McpError>> + MaybeSendFuture + '_ {
(**self).get_task_result(request, context)

View file

@ -105,6 +105,7 @@ impl IntoGetPromptResult for Vec<PromptMessage> {
Ok(GetPromptResult {
description: None,
messages: self,
meta: None,
})
}
}

View file

@ -137,7 +137,7 @@ use crate::{
tool::{CallToolHandler, DynCallToolHandler, ToolCallContext},
tool_name_validation::validate_and_warn_tool_name,
},
model::{CallToolResult, Content, ErrorCode, Tool, ToolAnnotations},
model::{CallToolResult, ContentBlock, ErrorCode, Tool, ToolAnnotations},
service::{MaybeBoxFuture, MaybeSend},
};
@ -149,7 +149,9 @@ fn into_tool_argument_error(error: crate::ErrorData) -> Result<CallToolResult, c
.message
.starts_with(TOOL_ARGUMENT_DESERIALIZATION_ERROR_PREFIX)
{
return Ok(CallToolResult::error(vec![Content::text(error.message)]));
return Ok(CallToolResult::error(vec![ContentBlock::text(
error.message,
)]));
}
Err(error)
@ -680,7 +682,7 @@ mod tests {
let text = result
.content
.first()
.and_then(|content| content.raw.as_text())
.and_then(|content| content.as_text())
.map(|text| text.text.as_str())
.expect("tool error result should include text");
assert!(text.contains("failed to deserialize parameters"));

View file

@ -35,7 +35,7 @@ pub struct ToolCallContext<'s, S> {
pub service: &'s S,
pub name: Cow<'static, str>,
pub arguments: Option<JsonObject>,
pub task: Option<JsonObject>,
pub task: Option<crate::model::TaskMetadata>,
}
impl<'s, S> ToolCallContext<'s, S> {

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,15 @@
use std::ops::{Deref, DerefMut};
//! Annotations for content blocks and resources.
//!
//! The `Annotations` struct carries optional hints about audience, priority, and freshness.
//! Individual content/resource types embed `annotations: Option<Annotations>` directly.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use super::{
RawAudioContent, RawContent, RawEmbeddedResource, RawImageContent, RawResource,
RawResourceTemplate, RawTextContent, Role,
};
use super::Role;
/// Optional annotations for the client. The client can use annotations to inform how objects are
/// used or displayed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
@ -35,192 +37,23 @@ impl Annotations {
audience: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct Annotated<T: AnnotateAble> {
#[serde(flatten)]
pub raw: T,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
pub fn with_audience(mut self, audience: Vec<Role>) -> Self {
self.audience = Some(audience);
self
}
impl<T: AnnotateAble> Deref for Annotated<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.raw
pub fn with_priority(mut self, priority: f32) -> Self {
self.priority = Some(priority);
self
}
}
impl<T: AnnotateAble> DerefMut for Annotated<T> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.raw
pub fn with_timestamp(mut self, timestamp: DateTime<Utc>) -> Self {
self.last_modified = Some(timestamp);
self
}
}
impl<T: AnnotateAble> Annotated<T> {
pub fn new(raw: T, annotations: Option<Annotations>) -> Self {
Self { raw, annotations }
}
pub fn remove_annotation(&mut self) -> Option<Annotations> {
self.annotations.take()
}
pub fn audience(&self) -> Option<&Vec<Role>> {
self.annotations.as_ref().and_then(|a| a.audience.as_ref())
}
pub fn priority(&self) -> Option<f32> {
self.annotations.as_ref().and_then(|a| a.priority)
}
pub fn timestamp(&self) -> Option<DateTime<Utc>> {
self.annotations.as_ref().and_then(|a| a.last_modified)
}
pub fn with_audience(self, audience: Vec<Role>) -> Annotated<T>
where
Self: Sized,
{
if let Some(annotations) = self.annotations {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
audience: Some(audience),
..annotations
}),
}
} else {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
audience: Some(audience),
priority: None,
last_modified: None,
}),
}
}
}
pub fn with_priority(self, priority: f32) -> Annotated<T>
where
Self: Sized,
{
if let Some(annotations) = self.annotations {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
priority: Some(priority),
..annotations
}),
}
} else {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
priority: Some(priority),
last_modified: None,
audience: None,
}),
}
}
}
pub fn with_timestamp(self, timestamp: DateTime<Utc>) -> Annotated<T>
where
Self: Sized,
{
if let Some(annotations) = self.annotations {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
last_modified: Some(timestamp),
..annotations
}),
}
} else {
Annotated {
raw: self.raw,
annotations: Some(Annotations {
last_modified: Some(timestamp),
priority: None,
audience: None,
}),
}
}
}
pub fn with_timestamp_now(self) -> Annotated<T>
where
Self: Sized,
{
self.with_timestamp(Utc::now())
}
}
mod sealed {
pub trait Sealed {}
}
macro_rules! annotate {
($T: ident) => {
impl sealed::Sealed for $T {}
impl AnnotateAble for $T {}
};
}
annotate!(RawContent);
annotate!(RawTextContent);
annotate!(RawImageContent);
annotate!(RawAudioContent);
annotate!(RawEmbeddedResource);
annotate!(RawResource);
annotate!(RawResourceTemplate);
pub trait AnnotateAble: sealed::Sealed {
fn optional_annotate(self, annotations: Option<Annotations>) -> Annotated<Self>
where
Self: Sized,
{
Annotated::new(self, annotations)
}
fn annotate(self, annotations: Annotations) -> Annotated<Self>
where
Self: Sized,
{
Annotated::new(self, Some(annotations))
}
fn no_annotation(self) -> Annotated<Self>
where
Self: Sized,
{
Annotated::new(self, None)
}
fn with_audience(self, audience: Vec<Role>) -> Annotated<Self>
where
Self: Sized,
{
self.annotate(Annotations {
audience: Some(audience),
..Default::default()
})
}
fn with_priority(self, priority: f32) -> Annotated<Self>
where
Self: Sized,
{
self.annotate(Annotations {
priority: Some(priority),
..Default::default()
})
}
fn with_timestamp(self, timestamp: DateTime<Utc>) -> Annotated<Self>
where
Self: Sized,
{
self.annotate(Annotations {
last_modified: Some(timestamp),
..Default::default()
})
}
fn with_timestamp_now(self) -> Annotated<Self>
where
Self: Sized,
{
pub fn with_timestamp_now(self) -> Self {
self.with_timestamp(Utc::now())
}
}

View file

@ -34,7 +34,7 @@ pub type ExtensionCapabilities = BTreeMap<String, JsonObject>;
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct PromptsCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
@ -43,7 +43,7 @@ pub struct PromptsCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ResourcesCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub subscribe: Option<bool>,
@ -54,7 +54,7 @@ pub struct ResourcesCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ToolsCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
@ -66,7 +66,7 @@ pub struct ToolsCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct RootsCapabilities {
#[serde(skip_serializing_if = "Option::is_none")]
pub list_changed: Option<bool>,
@ -76,7 +76,7 @@ pub struct RootsCapabilities {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct TasksCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub requests: Option<TaskRequestsCapability>,
@ -90,7 +90,7 @@ pub struct TasksCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct TaskRequestsCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub sampling: Option<SamplingTaskCapability>,
@ -106,7 +106,7 @@ pub struct TaskRequestsCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct SamplingTaskCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub create_message: Option<JsonObject>,
@ -115,7 +115,7 @@ pub struct SamplingTaskCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ElicitationTaskCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub create: Option<JsonObject>,
@ -124,7 +124,7 @@ pub struct ElicitationTaskCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ToolsTaskCapability {
#[serde(skip_serializing_if = "Option::is_none")]
pub call: Option<JsonObject>,
@ -205,7 +205,7 @@ impl TasksCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct FormElicitationCapability {
/// Whether the client supports JSON Schema validation for elicitation responses.
/// When true, the client will validate user input against the requested_schema
@ -214,19 +214,36 @@ pub struct FormElicitationCapability {
pub schema_validation: Option<bool>,
}
impl FormElicitationCapability {
pub fn new() -> Self {
Self::default()
}
pub fn with_schema_validation(mut self, enabled: bool) -> Self {
self.schema_validation = Some(enabled);
self
}
}
/// Capability for URL mode elicitation.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct UrlElicitationCapability {}
impl UrlElicitationCapability {
pub fn new() -> Self {
Self::default()
}
}
/// Elicitation allows servers to request interactive input from users during tool execution.
/// This capability indicates that a client can handle elicitation requests and present
/// appropriate UI to users for collecting the requested information.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ElicitationCapability {
/// Whether client supports form-based elicitation.
#[serde(skip_serializing_if = "Option::is_none")]
@ -236,6 +253,22 @@ pub struct ElicitationCapability {
pub url: Option<UrlElicitationCapability>,
}
impl ElicitationCapability {
pub fn new() -> Self {
Self::default()
}
pub fn with_form(mut self, form: FormElicitationCapability) -> Self {
self.form = Some(form);
self
}
pub fn with_url(mut self, url: UrlElicitationCapability) -> Self {
self.url = Some(url);
self
}
}
/// Sampling capability with optional sub-capabilities (SEP-1577).
///
/// Deprecated by SEP-2577; remains functional and will be removed in a future
@ -244,7 +277,7 @@ pub struct ElicitationCapability {
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct SamplingCapability {
/// Support for `tools` and `toolChoice` parameters
#[serde(skip_serializing_if = "Option::is_none")]

View file

@ -1,78 +1,177 @@
//! Content sent around agents, extensions, and LLMs
//! The various content types can be display to humans but also understood by models
//! They include optional annotations used to help inform agent usage
//! Content types that flow between agents, tools, prompts, and LLMs.
//!
//! The core union is [`ContentBlock`] (text | image | audio | resource_link | resource),
//! matching the MCP 2025-11-25 `ContentBlock` definition. Each variant carries optional
//! [`Annotations`] and `_meta` inline.
//!
//! [`SamplingMessageContentBlock`] extends the union with `tool_use` and `tool_result`
//! variants for sampling messages (SEP-1577).
use serde::{Deserialize, Serialize};
use serde_json::json;
use super::{AnnotateAble, Annotated, resource::ResourceContents};
use super::{Annotations, Meta, resource::ResourceContents};
// ---------------------------------------------------------------------------
// Flat content structs
// ---------------------------------------------------------------------------
/// Text content block (spec `TextContent`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawTextContent {
#[non_exhaustive]
pub struct TextContent {
/// The text content of the message.
pub text: String,
/// Optional protocol-level metadata for this content block
/// Optional protocol-level metadata for this content block.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
}
pub type TextContent = Annotated<RawTextContent>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawImageContent {
/// The base64-encoded image
pub data: String,
pub mime_type: String,
/// Optional protocol-level metadata for this content block
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this content.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
pub type ImageContent = Annotated<RawImageContent>;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawEmbeddedResource {
/// Optional protocol-level metadata for this content block
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
pub resource: ResourceContents,
}
impl RawEmbeddedResource {
/// Create a new RawEmbeddedResource.
pub fn new(resource: ResourceContents) -> Self {
impl TextContent {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
meta: None,
resource,
annotations: None,
}
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
pub type EmbeddedResource = Annotated<RawEmbeddedResource>;
/// Image content with base64-encoded data (spec `ImageContent`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ImageContent {
/// The base64-encoded image data.
pub data: String,
/// The MIME type of the image (e.g. `image/png`).
pub mime_type: String,
/// Optional protocol-level metadata for this content block.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this content.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
impl ImageContent {
pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self {
data: data.into(),
mime_type: mime_type.into(),
meta: None,
annotations: None,
}
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// Audio content with base64-encoded data (spec `AudioContent`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct AudioContent {
/// The base64-encoded audio data.
pub data: String,
/// The MIME type of the audio (e.g. `audio/wav`).
pub mime_type: String,
/// Optional protocol-level metadata for this content block.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this content.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
impl AudioContent {
pub fn new(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
Self {
data: data.into(),
mime_type: mime_type.into(),
meta: None,
annotations: None,
}
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// Embedded resource content (spec `EmbeddedResource`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct EmbeddedResource {
/// The embedded resource contents (text or blob).
pub resource: ResourceContents,
/// Optional protocol-level metadata for this content block.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this content.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
impl EmbeddedResource {
pub fn new(resource: ResourceContents) -> Self {
Self {
resource,
meta: None,
annotations: None,
}
}
pub fn get_text(&self) -> String {
match &self.resource {
ResourceContents::TextResourceContents { text, .. } => text.clone(),
_ => String::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawAudioContent {
pub data: String,
pub mime_type: String,
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub type AudioContent = Annotated<RawAudioContent>;
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// Tool call request from assistant (SEP-1577).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -80,15 +179,11 @@ pub type AudioContent = Annotated<RawAudioContent>;
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ToolUseContent {
/// Unique identifier for this tool call
pub id: String,
/// Name of the tool to call
pub name: String,
/// Input arguments for the tool
pub input: super::JsonObject,
/// Optional metadata (preserved for caching)
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
pub meta: Option<Meta>,
}
/// Tool execution result in user message (SEP-1577).
@ -97,18 +192,12 @@ pub struct ToolUseContent {
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct ToolResultContent {
/// Optional metadata
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<super::Meta>,
/// ID of the corresponding tool use
pub meta: Option<Meta>,
pub tool_use_id: String,
/// Content blocks returned by the tool
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub content: Vec<Content>,
/// Optional structured result
pub content: Vec<ContentBlock>,
#[serde(skip_serializing_if = "Option::is_none")]
pub structured_content: Option<super::JsonObject>,
/// Whether tool execution failed
#[serde(skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
@ -125,7 +214,7 @@ impl ToolUseContent {
}
impl ToolResultContent {
pub fn new(tool_use_id: impl Into<String>, content: Vec<Content>) -> Self {
pub fn new(tool_use_id: impl Into<String>, content: Vec<ContentBlock>) -> Self {
Self {
meta: None,
tool_use_id: tool_use_id.into(),
@ -135,7 +224,7 @@ impl ToolResultContent {
}
}
pub fn error(tool_use_id: impl Into<String>, content: Vec<Content>) -> Self {
pub fn error(tool_use_id: impl Into<String>, content: Vec<ContentBlock>) -> Self {
Self {
meta: None,
tool_use_id: tool_use_id.into(),
@ -146,21 +235,26 @@ impl ToolResultContent {
}
}
// ---------------------------------------------------------------------------
// ContentBlock — the unified content union (spec `ContentBlock`)
// ---------------------------------------------------------------------------
/// Unified content block union (spec `ContentBlock`).
///
/// `text | image | audio | resource_link | resource`
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
pub enum RawContent {
Text(RawTextContent),
Image(RawImageContent),
Resource(RawEmbeddedResource),
Audio(RawAudioContent),
ResourceLink(super::resource::RawResource),
#[non_exhaustive]
pub enum ContentBlock {
Text(TextContent),
Image(ImageContent),
Audio(AudioContent),
Resource(EmbeddedResource),
ResourceLink(super::resource::Resource),
}
pub type Content = Annotated<RawContent>;
impl RawContent {
impl ContentBlock {
pub fn json<S: Serialize>(json: S) -> Result<Self, crate::ErrorData> {
let json = serde_json::to_string(&json).map_err(|e| {
crate::ErrorData::internal_error(
@ -170,129 +264,106 @@ impl RawContent {
)),
)
})?;
Ok(RawContent::text(json))
Ok(ContentBlock::text(json))
}
pub fn text<S: Into<String>>(text: S) -> Self {
RawContent::Text(RawTextContent {
text: text.into(),
meta: None,
})
pub fn text(text: impl Into<String>) -> Self {
ContentBlock::Text(TextContent::new(text))
}
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
RawContent::Image(RawImageContent {
data: data.into(),
mime_type: mime_type.into(),
meta: None,
})
pub fn image(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
ContentBlock::Image(ImageContent::new(data, mime_type))
}
pub fn audio(data: impl Into<String>, mime_type: impl Into<String>) -> Self {
ContentBlock::Audio(AudioContent::new(data, mime_type))
}
pub fn resource(resource: ResourceContents) -> Self {
RawContent::Resource(RawEmbeddedResource {
meta: None,
resource,
})
ContentBlock::Resource(EmbeddedResource::new(resource))
}
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
RawContent::Resource(RawEmbeddedResource {
meta: None,
resource: ResourceContents::TextResourceContents {
pub fn embedded_text(uri: impl Into<String>, content: impl Into<String>) -> Self {
ContentBlock::Resource(EmbeddedResource::new(
ResourceContents::TextResourceContents {
uri: uri.into(),
mime_type: Some("text".to_string()),
text: content.into(),
meta: None,
},
})
))
}
/// Get the text content if this is a TextContent variant
pub fn as_text(&self) -> Option<&RawTextContent> {
pub fn resource_link(resource: super::resource::Resource) -> Self {
ContentBlock::ResourceLink(resource)
}
pub fn as_text(&self) -> Option<&TextContent> {
match self {
RawContent::Text(text) => Some(text),
ContentBlock::Text(text) => Some(text),
_ => None,
}
}
/// Get the image content if this is an ImageContent variant
pub fn as_image(&self) -> Option<&RawImageContent> {
pub fn as_image(&self) -> Option<&ImageContent> {
match self {
RawContent::Image(image) => Some(image),
ContentBlock::Image(image) => Some(image),
_ => None,
}
}
/// Get the resource content if this is an ImageContent variant
pub fn as_resource(&self) -> Option<&RawEmbeddedResource> {
pub fn as_resource(&self) -> Option<&EmbeddedResource> {
match self {
RawContent::Resource(resource) => Some(resource),
ContentBlock::Resource(resource) => Some(resource),
_ => None,
}
}
/// Get the resource link if this is a ResourceLink variant
pub fn as_resource_link(&self) -> Option<&super::resource::RawResource> {
pub fn as_resource_link(&self) -> Option<&super::resource::Resource> {
match self {
RawContent::ResourceLink(link) => Some(link),
ContentBlock::ResourceLink(link) => Some(link),
_ => None,
}
}
/// Create a resource link content
pub fn resource_link(resource: super::resource::RawResource) -> Self {
RawContent::ResourceLink(resource)
pub fn as_audio(&self) -> Option<&AudioContent> {
match self {
ContentBlock::Audio(audio) => Some(audio),
_ => None,
}
}
}
impl Content {
pub fn text<S: Into<String>>(text: S) -> Self {
RawContent::text(text).no_annotation()
}
pub fn image<S: Into<String>, T: Into<String>>(data: S, mime_type: T) -> Self {
RawContent::image(data, mime_type).no_annotation()
}
pub fn resource(resource: ResourceContents) -> Self {
RawContent::resource(resource).no_annotation()
}
pub fn embedded_text<S: Into<String>, T: Into<String>>(uri: S, content: T) -> Self {
RawContent::embedded_text(uri, content).no_annotation()
}
pub fn json<S: Serialize>(json: S) -> Result<Self, crate::ErrorData> {
RawContent::json(json).map(|c| c.no_annotation())
}
/// Create a resource link content
pub fn resource_link(resource: super::resource::RawResource) -> Self {
RawContent::resource_link(resource).no_annotation()
}
}
// ---------------------------------------------------------------------------
// JsonContent (unchanged)
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JsonContent<S: Serialize>(S);
/// Types that can be converted into a list of contents
// ---------------------------------------------------------------------------
// IntoContents
// ---------------------------------------------------------------------------
/// Types that can be converted into a list of content blocks.
pub trait IntoContents {
fn into_contents(self) -> Vec<Content>;
fn into_contents(self) -> Vec<ContentBlock>;
}
impl IntoContents for Content {
fn into_contents(self) -> Vec<Content> {
impl IntoContents for ContentBlock {
fn into_contents(self) -> Vec<ContentBlock> {
vec![self]
}
}
impl IntoContents for String {
fn into_contents(self) -> Vec<Content> {
vec![Content::text(self)]
fn into_contents(self) -> Vec<ContentBlock> {
vec![ContentBlock::text(self)]
}
}
impl IntoContents for () {
fn into_contents(self) -> Vec<Content> {
fn into_contents(self) -> Vec<ContentBlock> {
vec![]
}
}
@ -305,40 +376,32 @@ mod tests {
#[test]
fn test_image_content_serialization() {
let image_content = RawImageContent {
data: "base64data".to_string(),
mime_type: "image/png".to_string(),
meta: None,
};
let json = serde_json::to_string(&image_content).unwrap();
println!("ImageContent JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
let image = ImageContent::new("base64data", "image/png");
let json = serde_json::to_string(&image).unwrap();
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
#[test]
fn test_audio_content_serialization() {
let audio_content = RawAudioContent {
data: "base64audiodata".to_string(),
mime_type: "audio/wav".to_string(),
};
let json = serde_json::to_string(&audio_content).unwrap();
println!("AudioContent JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
let audio = AudioContent::new("base64audiodata", "audio/wav");
let json = serde_json::to_string(&audio).unwrap();
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
#[test]
fn test_resource_link_serialization() {
use super::super::resource::RawResource;
fn test_audio_content_has_meta() {
let audio = AudioContent::new("data", "audio/wav").with_meta(Meta::default());
let json = serde_json::to_value(&audio).unwrap();
assert!(json.get("_meta").is_some());
}
let resource_link = RawContent::ResourceLink(RawResource {
#[test]
fn test_resource_link_serialization() {
use super::super::resource::Resource;
let resource_link = ContentBlock::ResourceLink(Resource {
uri: "file:///test.txt".to_string(),
name: "test.txt".to_string(),
title: None,
@ -347,12 +410,10 @@ mod tests {
size: Some(100),
icons: None,
meta: None,
annotations: None,
});
let json = serde_json::to_string(&resource_link).unwrap();
println!("ResourceLink JSON: {}", json);
// Verify it contains the correct type tag
assert!(json.contains("\"type\":\"resource_link\""));
assert!(json.contains("\"uri\":\"file:///test.txt\""));
assert!(json.contains("\"name\":\"test.txt\""));
@ -368,9 +429,9 @@ mod tests {
"mimeType": "text/plain"
}"#;
let content: RawContent = serde_json::from_str(json).unwrap();
let content: ContentBlock = serde_json::from_str(json).unwrap();
if let RawContent::ResourceLink(resource) = content {
if let ContentBlock::ResourceLink(resource) = content {
assert_eq!(resource.uri, "file:///example.txt");
assert_eq!(resource.name, "example.txt");
assert_eq!(resource.description, Some("Example file".to_string()));
@ -379,4 +440,15 @@ mod tests {
panic!("Expected ResourceLink variant");
}
}
#[test]
fn test_content_block_text_with_annotations() {
let block = ContentBlock::Text(
TextContent::new("hello").with_annotations(Annotations::default().with_priority(0.8)),
);
let json = serde_json::to_value(&block).unwrap();
assert_eq!(json["type"], "text");
assert_eq!(json["text"], "hello");
assert_eq!(json["annotations"]["priority"], 0.8_f32);
}
}

View file

@ -49,8 +49,8 @@ const_string!(ArrayTypeConst = "array");
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
pub enum PrimitiveSchema {
#[non_exhaustive]
pub enum PrimitiveSchemaDefinition {
/// Enum property (explicit enum schema)
Enum(EnumSchema),
/// String property (with optional enum constraint)
@ -63,6 +63,9 @@ pub enum PrimitiveSchema {
Boolean(BooleanSchema),
}
#[deprecated(since = "2.0.0", note = "Renamed to PrimitiveSchemaDefinition")]
pub type PrimitiveSchema = PrimitiveSchemaDefinition;
// =============================================================================
// STRING SCHEMA
// =============================================================================
@ -71,7 +74,7 @@ pub enum PrimitiveSchema {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "kebab-case")]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum StringFormat {
/// Email address format
Email,
@ -346,7 +349,7 @@ impl NumberSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct IntegerSchema {
/// Type discriminator
#[serde(rename = "type")]
@ -513,7 +516,7 @@ impl BooleanSchema {
/// Represent single entry for titled item
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ConstTitle {
#[serde(rename = "const")]
pub const_: String,
@ -534,7 +537,7 @@ impl ConstTitle {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct LegacyEnumSchema {
#[serde(rename = "type")]
pub type_: StringTypeConst,
@ -546,6 +549,21 @@ pub struct LegacyEnumSchema {
pub enum_: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enum_names: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
impl LegacyEnumSchema {
pub fn new(enum_values: Vec<String>) -> Self {
Self {
type_: StringTypeConst,
title: None,
description: None,
enum_: enum_values,
enum_names: None,
default: None,
}
}
}
/// Untitled single-select
@ -601,7 +619,7 @@ impl TitledSingleSelectEnumSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum SingleSelectEnumSchema {
Untitled(UntitledSingleSelectEnumSchema),
Titled(TitledSingleSelectEnumSchema),
@ -610,7 +628,7 @@ pub enum SingleSelectEnumSchema {
/// Items for untitled multi-select options
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct UntitledItems {
#[serde(rename = "type")]
pub type_: StringTypeConst,
@ -618,10 +636,19 @@ pub struct UntitledItems {
pub enum_: Vec<String>,
}
impl UntitledItems {
pub fn new(enum_values: Vec<String>) -> Self {
Self {
type_: StringTypeConst,
enum_: enum_values,
}
}
}
/// Items for titled multi-select options
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct TitledItems {
// MCP spec requires "anyOf" for multi-select enums (allows any combination)
// Alias "oneOf" for compatibility with schemars
@ -727,7 +754,7 @@ impl TitledMultiSelectEnumSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum MultiSelectEnumSchema {
Untitled(UntitledMultiSelectEnumSchema),
Titled(TitledMultiSelectEnumSchema),
@ -751,7 +778,7 @@ pub enum MultiSelectEnumSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum EnumSchema {
Single(SingleSelectEnumSchema),
Multi(MultiSelectEnumSchema),
@ -1090,7 +1117,7 @@ impl EnumSchema {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "camelCase")]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct ElicitationSchema {
/// Always "object" for elicitation schemas
#[serde(rename = "type")]
@ -1101,7 +1128,7 @@ pub struct ElicitationSchema {
pub title: Option<Cow<'static, str>>,
/// Property definitions (must be primitive types)
pub properties: BTreeMap<String, PrimitiveSchema>,
pub properties: BTreeMap<String, PrimitiveSchemaDefinition>,
/// List of required property names
#[serde(skip_serializing_if = "Option::is_none")]
@ -1114,7 +1141,7 @@ pub struct ElicitationSchema {
impl ElicitationSchema {
/// Create a new elicitation schema with the given properties
pub fn new(properties: BTreeMap<String, PrimitiveSchema>) -> Self {
pub fn new(properties: BTreeMap<String, PrimitiveSchemaDefinition>) -> Self {
Self {
type_: ObjectTypeConst,
title: None,
@ -1237,7 +1264,7 @@ impl ElicitationSchema {
#[derive(Debug, Default)]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct ElicitationSchemaBuilder {
pub properties: BTreeMap<String, PrimitiveSchema>,
pub properties: BTreeMap<String, PrimitiveSchemaDefinition>,
pub required: Vec<String>,
pub title: Option<Cow<'static, str>>,
pub description: Option<Cow<'static, str>>,
@ -1250,13 +1277,17 @@ impl ElicitationSchemaBuilder {
}
/// Add a property to the schema
pub fn property(mut self, name: impl Into<String>, schema: PrimitiveSchema) -> Self {
pub fn property(mut self, name: impl Into<String>, schema: PrimitiveSchemaDefinition) -> Self {
self.properties.insert(name.into(), schema);
self
}
/// Add a required property to the schema
pub fn required_property(mut self, name: impl Into<String>, schema: PrimitiveSchema) -> Self {
pub fn required_property(
mut self,
name: impl Into<String>,
schema: PrimitiveSchemaDefinition,
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties.insert(name_str, schema);
@ -1264,7 +1295,7 @@ impl ElicitationSchemaBuilder {
}
// ===========================================================================
// TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchema wrapper
// TYPED PROPERTY METHODS - Cleaner API without PrimitiveSchemaDefinition wrapper
// ===========================================================================
/// Add a string property with custom builder (required)
@ -1273,8 +1304,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.properties
.insert(name.into(), PrimitiveSchema::String(f(StringSchema::new())));
self.properties.insert(
name.into(),
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
);
self
}
@ -1286,8 +1319,10 @@ impl ElicitationSchemaBuilder {
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::String(f(StringSchema::new())));
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
);
self
}
@ -1297,8 +1332,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.properties
.insert(name.into(), PrimitiveSchema::Number(f(NumberSchema::new())));
self.properties.insert(
name.into(),
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
);
self
}
@ -1310,8 +1347,10 @@ impl ElicitationSchemaBuilder {
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Number(f(NumberSchema::new())));
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
);
self
}
@ -1323,7 +1362,7 @@ impl ElicitationSchemaBuilder {
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchema::Integer(f(IntegerSchema::new())),
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
);
self
}
@ -1336,8 +1375,10 @@ impl ElicitationSchemaBuilder {
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Integer(f(IntegerSchema::new())));
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
);
self
}
@ -1349,7 +1390,7 @@ impl ElicitationSchemaBuilder {
) -> Self {
self.properties.insert(
name.into(),
PrimitiveSchema::Boolean(f(BooleanSchema::new())),
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
);
self
}
@ -1362,8 +1403,10 @@ impl ElicitationSchemaBuilder {
) -> Self {
let name_str = name.into();
self.required.push(name_str.clone());
self.properties
.insert(name_str, PrimitiveSchema::Boolean(f(BooleanSchema::new())));
self.properties.insert(
name_str,
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
);
self
}
@ -1373,22 +1416,28 @@ impl ElicitationSchemaBuilder {
/// Add a required string property
pub fn required_string(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::String(StringSchema::new()))
self.required_property(name, PrimitiveSchemaDefinition::String(StringSchema::new()))
}
/// Add an optional string property
pub fn optional_string(self, name: impl Into<String>) -> Self {
self.property(name, PrimitiveSchema::String(StringSchema::new()))
self.property(name, PrimitiveSchemaDefinition::String(StringSchema::new()))
}
/// Add a required email property
pub fn required_email(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::String(StringSchema::email()))
self.required_property(
name,
PrimitiveSchemaDefinition::String(StringSchema::email()),
)
}
/// Add an optional email property
pub fn optional_email(self, name: impl Into<String>) -> Self {
self.property(name, PrimitiveSchema::String(StringSchema::email()))
self.property(
name,
PrimitiveSchemaDefinition::String(StringSchema::email()),
)
}
/// Add a required string property with custom builder
@ -1397,7 +1446,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::String(f(StringSchema::new())))
self.required_property(
name,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
)
}
/// Add an optional string property with custom builder
@ -1406,7 +1458,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(StringSchema) -> StringSchema,
) -> Self {
self.property(name, PrimitiveSchema::String(f(StringSchema::new())))
self.property(
name,
PrimitiveSchemaDefinition::String(f(StringSchema::new())),
)
}
// Convenience methods for numbers
@ -1415,7 +1470,7 @@ impl ElicitationSchemaBuilder {
pub fn required_number(self, name: impl Into<String>, min: f64, max: f64) -> Self {
self.required_property(
name,
PrimitiveSchema::Number(NumberSchema::new().range(min, max)),
PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)),
)
}
@ -1423,7 +1478,7 @@ impl ElicitationSchemaBuilder {
pub fn optional_number(self, name: impl Into<String>, min: f64, max: f64) -> Self {
self.property(
name,
PrimitiveSchema::Number(NumberSchema::new().range(min, max)),
PrimitiveSchemaDefinition::Number(NumberSchema::new().range(min, max)),
)
}
@ -1433,7 +1488,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Number(f(NumberSchema::new())))
self.required_property(
name,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
)
}
/// Add an optional number property with custom builder
@ -1442,7 +1500,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(NumberSchema) -> NumberSchema,
) -> Self {
self.property(name, PrimitiveSchema::Number(f(NumberSchema::new())))
self.property(
name,
PrimitiveSchemaDefinition::Number(f(NumberSchema::new())),
)
}
// Convenience methods for integers
@ -1451,7 +1512,7 @@ impl ElicitationSchemaBuilder {
pub fn required_integer(self, name: impl Into<String>, min: i64, max: i64) -> Self {
self.required_property(
name,
PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)),
PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)),
)
}
@ -1459,7 +1520,7 @@ impl ElicitationSchemaBuilder {
pub fn optional_integer(self, name: impl Into<String>, min: i64, max: i64) -> Self {
self.property(
name,
PrimitiveSchema::Integer(IntegerSchema::new().range(min, max)),
PrimitiveSchemaDefinition::Integer(IntegerSchema::new().range(min, max)),
)
}
@ -1469,7 +1530,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Integer(f(IntegerSchema::new())))
self.required_property(
name,
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
)
}
/// Add an optional integer property with custom builder
@ -1478,21 +1542,27 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(IntegerSchema) -> IntegerSchema,
) -> Self {
self.property(name, PrimitiveSchema::Integer(f(IntegerSchema::new())))
self.property(
name,
PrimitiveSchemaDefinition::Integer(f(IntegerSchema::new())),
)
}
// Convenience methods for booleans
/// Add a required boolean property
pub fn required_bool(self, name: impl Into<String>) -> Self {
self.required_property(name, PrimitiveSchema::Boolean(BooleanSchema::new()))
self.required_property(
name,
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new()),
)
}
/// Add an optional boolean property with default value
pub fn optional_bool(self, name: impl Into<String>, default: bool) -> Self {
self.property(
name,
PrimitiveSchema::Boolean(BooleanSchema::new().with_default(default)),
PrimitiveSchemaDefinition::Boolean(BooleanSchema::new().with_default(default)),
)
}
@ -1502,7 +1572,10 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.required_property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new())))
self.required_property(
name,
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
)
}
/// Add an optional boolean property with custom builder
@ -1511,19 +1584,22 @@ impl ElicitationSchemaBuilder {
name: impl Into<String>,
f: impl FnOnce(BooleanSchema) -> BooleanSchema,
) -> Self {
self.property(name, PrimitiveSchema::Boolean(f(BooleanSchema::new())))
self.property(
name,
PrimitiveSchemaDefinition::Boolean(f(BooleanSchema::new())),
)
}
// Enum convenience methods
/// Add a required enum property using EnumSchema
pub fn required_enum_schema(self, name: impl Into<String>, enum_schema: EnumSchema) -> Self {
self.required_property(name, PrimitiveSchema::Enum(enum_schema))
self.required_property(name, PrimitiveSchemaDefinition::Enum(enum_schema))
}
/// Add an optional enum property using EnumSchema
pub fn optional_enum_schema(self, name: impl Into<String>, enum_schema: EnumSchema) -> Self {
self.property(name, PrimitiveSchema::Enum(enum_schema))
self.property(name, PrimitiveSchemaDefinition::Enum(enum_schema))
}
/// Add a required enum property using values. Creates an untitled single-select enum.
@ -1534,12 +1610,13 @@ impl ElicitationSchemaBuilder {
pub fn required_enum(self, name: impl Into<String>, values: Vec<String>) -> Self {
self.required_property(
name,
PrimitiveSchema::Enum(EnumSchema::Legacy(LegacyEnumSchema {
PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: None,
description: None,
enum_: values,
enum_names: None,
default: None,
})),
)
}
@ -1552,12 +1629,13 @@ impl ElicitationSchemaBuilder {
pub fn optional_enum(self, name: impl Into<String>, values: Vec<String>) -> Self {
self.property(
name,
PrimitiveSchema::Enum(EnumSchema::Legacy(LegacyEnumSchema {
PrimitiveSchemaDefinition::Enum(EnumSchema::Legacy(LegacyEnumSchema {
type_: StringTypeConst,
title: None,
description: None,
enum_: values,
enum_names: None,
default: None,
})),
)
}
@ -1732,6 +1810,7 @@ mod tests {
description: Some("A legacy enum schema".into()),
enum_: vec!["A".to_string(), "B".to_string()],
enum_names: Some(vec!["Option A".to_string(), "Option B".to_string()]),
default: None,
});
let json = serde_json::to_value(&schema)?;
@ -1776,6 +1855,7 @@ mod tests {
description: None,
enum_: vec!["a".to_string(), "b".to_string()],
enum_names: None,
default: None,
});
let json = serde_json::to_value(&schema)?;
assert!(!json.as_object().unwrap().contains_key("enumNames"));
@ -1970,14 +2050,14 @@ mod tests {
"type": "string",
"enum": ["a", "b"]
});
let schema: PrimitiveSchema = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchema::Enum(_)));
let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchemaDefinition::Enum(_)));
// Test that string schemas deserialize as String variant
let json = json!({
"type": "string"
});
let schema: PrimitiveSchema = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchema::String(_)));
let schema: PrimitiveSchemaDefinition = serde_json::from_value(json).unwrap();
assert!(matches!(schema, PrimitiveSchemaDefinition::String(_)));
}
#[test]

View file

@ -5,7 +5,7 @@ use serde_json::Value;
use super::{
ClientNotification, ClientRequest, CustomNotification, CustomRequest, Extensions, JsonObject,
JsonRpcMessage, NumberOrString, ProgressToken, ServerNotification, ServerRequest,
JsonRpcMessage, NumberOrString, ProgressToken, ServerNotification, ServerRequest, TaskMetadata,
};
pub trait GetMeta {
@ -54,11 +54,11 @@ pub trait RequestParamsMeta {
/// can include a `task` field to signal that the caller wants task-augmented execution.
pub trait TaskAugmentedRequestParamsMeta: RequestParamsMeta {
/// Get a reference to the task field
fn task(&self) -> Option<&JsonObject>;
fn task(&self) -> Option<&TaskMetadata>;
/// Get a mutable reference to the task field
fn task_mut(&mut self) -> &mut Option<JsonObject>;
fn task_mut(&mut self) -> &mut Option<TaskMetadata>;
/// Set the task field
fn set_task(&mut self, task: JsonObject) {
fn set_task(&mut self, task: TaskMetadata) {
*self.task_mut() = Some(task);
}
}
@ -152,9 +152,9 @@ variant_extension! {
CallToolRequest
ListToolsRequest
CustomRequest
GetTaskInfoRequest
GetTaskRequest
ListTasksRequest
GetTaskResultRequest
GetTaskPayloadRequest
CancelTaskRequest
}
}
@ -164,7 +164,7 @@ variant_extension! {
PingRequest
CreateMessageRequest
ListRootsRequest
CreateElicitationRequest
ElicitRequest
CustomRequest
}
}
@ -175,6 +175,7 @@ variant_extension! {
ProgressNotification
InitializedNotification
RootsListChangedNotification
TaskStatusNotification
CustomNotification
}
}
@ -188,7 +189,8 @@ variant_extension! {
ResourceListChangedNotification
ToolListChangedNotification
PromptListChangedNotification
ElicitationCompletionNotification
ElicitationCompleteNotification
TaskStatusNotification
CustomNotification
}
}

View file

@ -1,37 +1,31 @@
use serde::{Deserialize, Serialize};
use super::{
AnnotateAble, Annotations, Icon, Meta, RawEmbeddedResource,
content::{AudioContent, EmbeddedResource, ImageContent},
Annotations, ContentBlock, Icon, Meta, Role,
content::{AudioContent, EmbeddedResource, ImageContent, TextContent},
resource::ResourceContents,
};
/// A prompt that can be used to generate text from a model
/// A prompt or prompt template that the server offers (spec `Prompt`).
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct Prompt {
/// The name of the prompt
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Optional description of what the prompt does
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Optional arguments that can be passed to customize the prompt
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<Vec<PromptArgument>>,
/// Optional list of icons for the prompt
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
/// Optional additional metadata for this prompt
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
}
impl Prompt {
/// Create a new prompt with the given name, description and arguments
pub fn new<N, D>(
name: N,
description: Option<D>,
@ -51,7 +45,6 @@ impl Prompt {
}
}
/// Create a new prompt from raw fields (used by the macro)
pub fn from_raw(
name: impl Into<String>,
description: Option<impl Into<String>>,
@ -67,45 +60,37 @@ impl Prompt {
}
}
/// Set the human-readable title
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// Set the icons
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
/// Set the metadata
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
}
/// Represents a prompt argument that can be passed to customize the prompt
/// Describes an argument that a prompt can accept (spec `PromptArgument`).
#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct PromptArgument {
/// The name of the argument
pub name: String,
/// A human-readable title for the argument
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// A description of what the argument is used for
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Whether this argument is required
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<bool>,
}
impl PromptArgument {
/// Create a new prompt argument
pub fn new<N: Into<String>>(name: N) -> Self {
PromptArgument {
name: name.into(),
@ -115,108 +100,51 @@ impl PromptArgument {
}
}
/// Set the title
pub fn with_title<T: Into<String>>(mut self, title: T) -> Self {
self.title = Some(title.into());
self
}
/// Set the description
pub fn with_description<D: Into<String>>(mut self, description: D) -> Self {
self.description = Some(description.into());
self
}
/// Set the required flag
pub fn with_required(mut self, required: bool) -> Self {
self.required = Some(required);
self
}
}
/// Represents the role of a message sender in a prompt conversation
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
pub enum PromptMessageRole {
User,
Assistant,
}
/// Content types that can be included in prompt messages
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
pub enum PromptMessageContent {
/// Plain text content
Text { text: String },
/// Image content with base64-encoded data
Image {
#[serde(flatten)]
image: ImageContent,
},
/// Audio content with base64-encoded data
Audio {
#[serde(flatten)]
audio: AudioContent,
},
/// Embedded server-side resource
Resource {
#[serde(flatten)]
resource: EmbeddedResource,
},
/// A link to a resource that can be fetched separately
ResourceLink {
#[serde(flatten)]
link: super::resource::Resource,
},
}
impl PromptMessageContent {
pub fn text(text: impl Into<String>) -> Self {
Self::Text { text: text.into() }
}
/// Create a resource link content
pub fn resource_link(resource: super::resource::Resource) -> Self {
Self::ResourceLink { link: resource }
}
}
/// A message in a prompt conversation
/// A message returned as part of a prompt (spec `PromptMessage`).
///
/// Uses the unified `ContentBlock` for its content (text | image | audio | resource_link | resource).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct PromptMessage {
/// The role of the message sender
pub role: PromptMessageRole,
/// The content of the message
pub content: PromptMessageContent,
pub role: Role,
pub content: ContentBlock,
}
impl PromptMessage {
/// Create a new prompt message with the given role and content
pub fn new(role: PromptMessageRole, content: PromptMessageContent) -> Self {
pub fn new(role: Role, content: ContentBlock) -> Self {
Self { role, content }
}
/// Create a new text message with the given role and text content
pub fn new_text<S: Into<String>>(role: PromptMessageRole, text: S) -> Self {
pub fn new_text<S: Into<String>>(role: Role, text: S) -> Self {
Self {
role,
content: PromptMessageContent::Text { text: text.into() },
content: ContentBlock::text(text),
}
}
/// Create a new image message. `meta` and `annotations` are optional.
#[cfg(feature = "base64")]
pub fn new_image(
role: PromptMessageRole,
role: Role,
data: &[u8],
mime_type: &str,
meta: Option<crate::model::Meta>,
meta: Option<Meta>,
annotations: Option<Annotations>,
) -> Self {
use base64::{Engine, prelude::BASE64_STANDARD};
@ -224,23 +152,21 @@ impl PromptMessage {
let base64 = BASE64_STANDARD.encode(data);
Self {
role,
content: PromptMessageContent::Image {
image: crate::model::RawImageContent {
data: base64,
mime_type: mime_type.into(),
meta,
}
.optional_annotate(annotations),
},
content: ContentBlock::Image(ImageContent {
data: base64,
mime_type: mime_type.into(),
meta,
annotations,
}),
}
}
/// Create a new audio message. `annotations` is optional.
#[cfg(feature = "base64")]
pub fn new_audio(
role: PromptMessageRole,
role: Role,
data: &[u8],
mime_type: &str,
meta: Option<Meta>,
annotations: Option<Annotations>,
) -> Self {
use base64::{Engine, prelude::BASE64_STANDARD};
@ -248,24 +174,22 @@ impl PromptMessage {
let base64 = BASE64_STANDARD.encode(data);
Self {
role,
content: PromptMessageContent::Audio {
audio: crate::model::RawAudioContent {
data: base64,
mime_type: mime_type.into(),
}
.optional_annotate(annotations),
},
content: ContentBlock::Audio(AudioContent {
data: base64,
mime_type: mime_type.into(),
meta,
annotations,
}),
}
}
/// Create a new resource message. `resource_meta`, `resource_content_meta`, and `annotations` are optional.
pub fn new_resource(
role: PromptMessageRole,
role: Role,
uri: String,
mime_type: Option<String>,
text: Option<String>,
resource_meta: Option<crate::model::Meta>,
resource_content_meta: Option<crate::model::Meta>,
resource_meta: Option<Meta>,
resource_content_meta: Option<Meta>,
annotations: Option<Annotations>,
) -> Self {
let resource_contents = match text {
@ -284,31 +208,29 @@ impl PromptMessage {
};
Self {
role,
content: PromptMessageContent::Resource {
resource: RawEmbeddedResource {
meta: resource_meta,
resource: resource_contents,
}
.optional_annotate(annotations),
},
content: ContentBlock::Resource(EmbeddedResource {
meta: resource_meta,
resource: resource_contents,
annotations,
}),
}
}
/// Note: PromptMessage text content does not carry protocol-level _meta per current schema.
/// This function exists for API symmetry but ignores the meta parameter.
pub fn new_text_with_meta<S: Into<String>>(
role: PromptMessageRole,
text: S,
_meta: Option<crate::model::Meta>,
) -> Self {
Self::new_text(role, text)
}
/// Create a new resource link message
pub fn new_resource_link(role: PromptMessageRole, resource: super::resource::Resource) -> Self {
pub fn new_text_with_meta<S: Into<String>>(role: Role, text: S, meta: Option<Meta>) -> Self {
Self {
role,
content: PromptMessageContent::ResourceLink { link: resource },
content: ContentBlock::Text(TextContent {
text: text.into(),
meta,
annotations: None,
}),
}
}
pub fn new_resource_link(role: Role, resource: super::resource::Resource) -> Self {
Self {
role,
content: ContentBlock::ResourceLink(resource),
}
}
}
@ -321,35 +243,15 @@ mod tests {
#[test]
fn test_prompt_message_image_serialization() {
let image_content = crate::model::RawImageContent {
data: "base64data".to_string(),
mime_type: "image/png".to_string(),
meta: None,
};
let json = serde_json::to_string(&image_content).unwrap();
println!("PromptMessage ImageContent JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
let image = ImageContent::new("base64data", "image/png");
let json = serde_json::to_string(&image).unwrap();
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
#[test]
fn test_prompt_message_audio_serialization_and_deserialization() {
// Audio is part of the spec's ContentBlock union for prompt messages
// (text | image | audio | resource_link | resource). Ensure the Audio
// variant serializes to the flat, spec-compliant shape
// `{ "type": "audio", "data", "mimeType" }` and parses back.
// See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts
let content = PromptMessageContent::Audio {
audio: crate::model::RawAudioContent {
data: "YXVkaW8=".to_string(),
mime_type: "audio/wav".to_string(),
}
.no_annotation(),
};
let content = ContentBlock::Audio(AudioContent::new("YXVkaW8=", "audio/wav"));
let value = serde_json::to_value(&content).unwrap();
assert_eq!(value.get("type").and_then(|v| v.as_str()), Some("audio"));
assert_eq!(value.get("data").and_then(|v| v.as_str()), Some("YXVkaW8="));
@ -359,18 +261,15 @@ mod tests {
"expected camelCase mimeType, got: {value:#?}"
);
// Regression: a spec-valid audio content block must deserialize into
// the Audio variant (previously failed with "unknown variant `audio`").
let json = r#"{"type":"audio","data":"YXVkaW8=","mimeType":"audio/wav"}"#;
let parsed: PromptMessageContent = serde_json::from_str(json).unwrap();
let parsed: ContentBlock = serde_json::from_str(json).unwrap();
assert_eq!(parsed, content);
}
#[test]
#[cfg(feature = "base64")]
fn test_prompt_message_new_audio_constructor() {
let message =
PromptMessage::new_audio(PromptMessageRole::User, b"hello", "audio/wav", None);
let message = PromptMessage::new_audio(Role::User, b"hello", "audio/wav", None, None);
let value = serde_json::to_value(&message).unwrap();
let content = value.get("content").expect("content present");
assert_eq!(content.get("type").and_then(|v| v.as_str()), Some("audio"));
@ -378,7 +277,6 @@ mod tests {
content.get("mimeType").and_then(|v| v.as_str()),
Some("audio/wav")
);
// base64 of "hello"
assert_eq!(
content.get("data").and_then(|v| v.as_str()),
Some("aGVsbG8=")
@ -387,16 +285,12 @@ mod tests {
#[test]
fn test_prompt_message_resource_link_serialization() {
use super::super::resource::RawResource;
use super::super::resource::Resource;
let resource = RawResource::new("file:///test.txt", "test.txt");
let message =
PromptMessage::new_resource_link(PromptMessageRole::User, resource.no_annotation());
let resource = Resource::new("file:///test.txt", "test.txt");
let message = PromptMessage::new_resource_link(Role::User, resource);
let json = serde_json::to_string(&message).unwrap();
println!("PromptMessage with ResourceLink JSON: {}", json);
// Verify it contains the correct type tag
assert!(json.contains("\"type\":\"resource_link\""));
assert!(json.contains("\"uri\":\"file:///test.txt\""));
assert!(json.contains("\"name\":\"test.txt\""));
@ -404,12 +298,8 @@ mod tests {
#[test]
fn test_prompt_message_resource_serialization_is_flat() {
// Regression test: PromptMessageContent::Resource must serialize to
// the spec-compliant flat shape `{ "type": "resource", "resource": { "uri", "mimeType", "text" } }`
// and NOT the double-nested shape `{ "type": "resource", "resource": { "resource": {...} } }`.
// See: https://modelcontextprotocol.io/specification/2025-06-18/server/prompts
let message = PromptMessage::new_resource(
PromptMessageRole::User,
Role::User,
"alc://packages/sc/narrative".to_string(),
Some("text/markdown".to_string()),
Some("# Hello".to_string()),
@ -419,8 +309,6 @@ mod tests {
);
let value: serde_json::Value = serde_json::to_value(&message).unwrap();
// Drill into content
let content = value.get("content").expect("content present");
assert_eq!(
content.get("type").and_then(|v| v.as_str()),
@ -431,7 +319,6 @@ mod tests {
.get("resource")
.expect("resource field present at content level");
// Spec-compliant: resource.uri / resource.mimeType / resource.text MUST be flat
assert_eq!(
resource.get("uri").and_then(|v| v.as_str()),
Some("alc://packages/sc/narrative"),
@ -446,7 +333,6 @@ mod tests {
Some("# Hello")
);
// Regression guard: content.resource MUST NOT contain a nested `resource` key.
assert!(
resource.get("resource").is_none(),
"double-nested resource detected (regression): {resource:#?}"
@ -463,13 +349,13 @@ mod tests {
"mimeType": "text/plain"
}"#;
let content: PromptMessageContent = serde_json::from_str(json).unwrap();
let content: ContentBlock = serde_json::from_str(json).unwrap();
if let PromptMessageContent::ResourceLink { link } = content {
assert_eq!(link.uri, "file:///example.txt");
assert_eq!(link.name, "example.txt");
assert_eq!(link.description, Some("Example file".to_string()));
assert_eq!(link.mime_type, Some("text/plain".to_string()));
if let ContentBlock::ResourceLink(resource) = content {
assert_eq!(resource.uri, "file:///example.txt");
assert_eq!(resource.name, "example.txt");
assert_eq!(resource.description, Some("Example file".to_string()));
assert_eq!(resource.mime_type, Some("text/plain".to_string()));
} else {
panic!("Expected ResourceLink variant");
}

View file

@ -1,66 +1,173 @@
use serde::{Deserialize, Serialize};
use super::{Annotated, Icon, Meta};
use super::{Annotations, Icon, Meta};
/// Represents a resource in the extension with metadata
/// A known resource that the server is capable of reading (spec `Resource`).
///
/// Also used as the inner type of `ContentBlock::ResourceLink` (spec `ResourceLink extends Resource`).
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawResource {
/// URI representing the resource location (e.g., "file:///path/to/file" or "str:///content")
#[non_exhaustive]
pub struct Resource {
/// The URI of this resource (e.g. `file:///path/to/file`).
pub uri: String,
/// Name of the resource
/// The programmatic name of the resource.
pub name: String,
/// Human-readable title of the resource
/// Optional human-readable display title.
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Optional description of the resource
/// Optional description of what this resource represents.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// MIME type of the resource content ("text" or "blob")
/// The MIME type of this resource, if known.
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known.
///
/// This can be used by Hosts to display file sizes and estimate context window us
/// The size of the raw resource content in bytes (before base64/tokenization), if known.
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u32>,
/// Optional list of icons for the resource
pub size: Option<u64>,
/// Optional set of icons the client may display for this resource.
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
/// Optional additional metadata for this resource
/// Optional protocol-level metadata for this resource.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this resource.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
pub type Resource = Annotated<RawResource>;
impl Resource {
pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri: uri.into(),
name: name.into(),
title: None,
description: None,
mime_type: None,
size: None,
icons: None,
meta: None,
annotations: None,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// A template description for resources available on the server (spec `ResourceTemplate`).
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct RawResourceTemplate {
#[non_exhaustive]
pub struct ResourceTemplate {
/// An RFC 6570 URI template for constructing resource URIs.
pub uri_template: String,
/// The programmatic name of the resource template.
pub name: String,
/// Optional human-readable display title.
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
/// Optional description of what this template is for.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// The MIME type for resources matching this template, if uniform.
#[serde(skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
/// Optional list of icons for the resource template
/// Optional set of icons the client may display for this template.
#[serde(skip_serializing_if = "Option::is_none")]
pub icons: Option<Vec<Icon>>,
/// Optional protocol-level metadata for this resource template.
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
/// Optional annotations describing how the client should use this template.
#[serde(skip_serializing_if = "Option::is_none")]
pub annotations: Option<Annotations>,
}
pub type ResourceTemplate = Annotated<RawResourceTemplate>;
impl ResourceTemplate {
pub fn new(uri_template: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri_template: uri_template.into(),
name: name.into(),
title: None,
description: None,
mime_type: None,
icons: None,
meta: None,
annotations: None,
}
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
pub fn with_annotations(mut self, annotations: Annotations) -> Self {
self.annotations = Some(annotations);
self
}
}
/// The contents of a specific resource or sub-resource.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
#[serde(untagged)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum ResourceContents {
#[serde(rename_all = "camelCase")]
TextResourceContents {
@ -83,7 +190,6 @@ pub enum ResourceContents {
}
impl ResourceContents {
/// Create text resource contents.
pub fn text(text: impl Into<String>, uri: impl Into<String>) -> Self {
Self::TextResourceContents {
uri: uri.into(),
@ -93,7 +199,6 @@ impl ResourceContents {
}
}
/// Create blob resource contents.
pub fn blob(blob: impl Into<String>, uri: impl Into<String>) -> Self {
Self::BlobResourceContents {
uri: uri.into(),
@ -103,7 +208,6 @@ impl ResourceContents {
}
}
/// Set the MIME type on this resource contents.
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
match &mut self {
Self::TextResourceContents { mime_type: mt, .. } => *mt = Some(mime_type.into()),
@ -112,7 +216,6 @@ impl ResourceContents {
self
}
/// Set the metadata on this resource contents.
pub fn with_meta(mut self, meta: Meta) -> Self {
match &mut self {
Self::TextResourceContents { meta: m, .. } => *m = Some(meta),
@ -122,96 +225,6 @@ impl ResourceContents {
}
}
impl RawResource {
/// Creates a new Resource from a URI with explicit mime type
pub fn new(uri: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri: uri.into(),
name: name.into(),
title: None,
description: None,
mime_type: None,
size: None,
icons: None,
meta: None,
}
}
/// Set the human-readable title.
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// Set the description.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Set the MIME type.
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
/// Set the size in bytes.
pub fn with_size(mut self, size: u32) -> Self {
self.size = Some(size);
self
}
/// Set the icons.
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
/// Set the metadata.
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
}
impl RawResourceTemplate {
/// Creates a new RawResourceTemplate with a URI template and name.
pub fn new(uri_template: impl Into<String>, name: impl Into<String>) -> Self {
Self {
uri_template: uri_template.into(),
name: name.into(),
title: None,
description: None,
mime_type: None,
icons: None,
}
}
/// Set the human-readable title.
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// Set the description.
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
/// Set the MIME type.
pub fn with_mime_type(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
/// Set the icons.
pub fn with_icons(mut self, icons: Vec<Icon>) -> Self {
self.icons = Some(icons);
self
}
}
#[cfg(test)]
mod tests {
use serde_json;
@ -221,21 +234,12 @@ mod tests {
#[test]
fn test_resource_serialization() {
let resource = RawResource {
uri: "file:///test.txt".to_string(),
title: None,
name: "test".to_string(),
description: Some("Test resource".to_string()),
mime_type: Some("text/plain".to_string()),
size: Some(100),
icons: None,
meta: None,
};
let resource = Resource::new("file:///test.txt", "test")
.with_description("Test resource")
.with_mime_type("text/plain")
.with_size(100);
let json = serde_json::to_string(&resource).unwrap();
println!("Serialized JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
@ -250,28 +254,22 @@ mod tests {
};
let json = serde_json::to_string(&text_contents).unwrap();
println!("ResourceContents JSON: {}", json);
// Verify it contains mimeType (camelCase) not mime_type (snake_case)
assert!(json.contains("mimeType"));
assert!(!json.contains("mime_type"));
}
#[test]
fn test_resource_template_with_icons() {
let resource_template = RawResourceTemplate {
uri_template: "file:///{path}".to_string(),
name: "template".to_string(),
title: Some("Test Template".to_string()),
description: Some("A test resource template".to_string()),
mime_type: Some("text/plain".to_string()),
icons: Some(vec![Icon {
let resource_template = ResourceTemplate::new("file:///{path}", "template")
.with_title("Test Template")
.with_description("A test resource template")
.with_mime_type("text/plain")
.with_icons(vec![Icon {
src: "https://example.com/icon.png".to_string(),
mime_type: Some("image/png".to_string()),
sizes: Some(vec!["48x48".to_string()]),
theme: Some(IconTheme::Light),
}]),
};
}]);
let json = serde_json::to_value(&resource_template).unwrap();
assert!(json["icons"].is_array());
@ -282,16 +280,31 @@ mod tests {
#[test]
fn test_resource_template_without_icons() {
let resource_template = RawResourceTemplate {
uri_template: "file:///{path}".to_string(),
name: "template".to_string(),
title: None,
description: None,
mime_type: None,
icons: None,
};
let resource_template = ResourceTemplate::new("file:///{path}", "template");
let json = serde_json::to_value(&resource_template).unwrap();
assert!(json.get("icons").is_none());
}
#[test]
fn test_resource_size_u64() {
let resource = Resource::new("file:///big", "big").with_size(5_000_000_000);
let json = serde_json::to_value(&resource).unwrap();
assert_eq!(json["size"], 5_000_000_000_u64);
}
#[test]
fn test_resource_with_annotations() {
let resource = Resource::new("file:///test.txt", "test")
.with_annotations(Annotations::default().with_priority(0.9));
let json = serde_json::to_value(&resource).unwrap();
assert_eq!(json["annotations"]["priority"], 0.9_f32);
}
#[test]
fn test_resource_template_with_meta() {
let resource_template =
ResourceTemplate::new("file:///{path}", "template").with_meta(Meta::default());
let json = serde_json::to_value(&resource_template).unwrap();
assert!(json.get("_meta").is_some());
}
}

View file

@ -3,11 +3,54 @@ use serde_json::Value;
use super::Meta;
/// Metadata for augmenting a request with task execution (spec `TaskMetadata`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct TaskMetadata {
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl: Option<u64>,
}
impl TaskMetadata {
pub fn new() -> Self {
Self::default()
}
pub fn with_ttl(mut self, ttl: u64) -> Self {
self.ttl = Some(ttl);
self
}
}
/// Metadata for associating messages with a task (spec `RelatedTaskMetadata`).
///
/// Carried in `_meta` under the key `"io.modelcontextprotocol/related-task"`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[non_exhaustive]
pub struct RelatedTaskMetadata {
pub task_id: String,
}
impl RelatedTaskMetadata {
pub fn new(task_id: impl Into<String>) -> Self {
Self {
task_id: task_id.into(),
}
}
/// The well-known `_meta` key for related-task metadata.
pub const META_KEY: &str = "io.modelcontextprotocol/related-task";
}
/// Canonical task lifecycle status as defined by SEP-1686.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_enums, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub enum TaskStatus {
/// The receiver accepted the request and is currently working on it.
#[default]
@ -95,12 +138,20 @@ impl Task {
#[non_exhaustive]
pub struct CreateTaskResult {
pub task: Task,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
}
impl CreateTaskResult {
/// Create a new CreateTaskResult.
pub fn new(task: Task) -> Self {
Self { task }
Self { task, meta: None }
}
/// Sets the protocol-level metadata for this result.
pub fn with_meta(mut self, meta: Meta) -> Self {
self.meta = Some(meta);
self
}
}
@ -111,7 +162,7 @@ impl CreateTaskResult {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct GetTaskResult {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
@ -119,6 +170,12 @@ pub struct GetTaskResult {
pub task: Task,
}
impl GetTaskResult {
pub fn new(task: Task) -> Self {
Self { meta: None, task }
}
}
/// Response to a `tasks/result` request.
///
/// Per spec, the result structure matches the original request type
@ -162,7 +219,7 @@ impl<'de> serde::Deserialize<'de> for GetTaskPayloadResult {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
#[non_exhaustive]
pub struct CancelTaskResult {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<Meta>,
@ -170,26 +227,8 @@ pub struct CancelTaskResult {
pub task: Task,
}
/// Paginated list of tasks
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[expect(clippy::exhaustive_structs, reason = "intentionally exhaustive")]
pub struct TaskList {
pub tasks: Vec<Task>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total: Option<u64>,
}
impl TaskList {
/// Create a new TaskList.
pub fn new(tasks: Vec<Task>) -> Self {
Self {
tasks,
next_cursor: None,
total: None,
}
impl CancelTaskResult {
pub fn new(task: Task) -> Self {
Self { meta: None, task }
}
}

View file

@ -400,8 +400,9 @@ impl<R: ServiceRole> RequestHandle<R> {
async fn send_timeout_cancel_notification(&self, reason: &str) {
let notification = CancelledNotification {
params: CancelledNotificationParam {
request_id: self.id.clone(),
request_id: Some(self.id.clone()),
reason: Some(reason.to_owned()),
meta: None,
},
method: crate::model::CancelledNotificationMethod,
extensions: Default::default(),
@ -473,8 +474,9 @@ impl<R: ServiceRole> RequestHandle<R> {
.await;
let notification = CancelledNotification {
params: CancelledNotificationParam {
request_id: self.id,
request_id: Some(self.id),
reason,
meta: None,
},
method: crate::model::CancelledNotificationMethod,
extensions: Default::default(),
@ -1084,11 +1086,13 @@ where
};
let _ = responder.send(response);
if let Some(param) = cancellation_param {
if let Some(responder) = local_responder_pool.remove(&param.request_id) {
tracing::info!(id = %param.request_id, reason = param.reason, "cancelled");
let _response_result = responder.send(Err(ServiceError::Cancelled {
reason: param.reason.clone(),
}));
if let Some(request_id) = &param.request_id {
if let Some(responder) = local_responder_pool.remove(request_id) {
tracing::info!(id = %request_id, reason = param.reason, "cancelled");
let _response_result = responder.send(Err(ServiceError::Cancelled {
reason: param.reason.clone(),
}));
}
}
}
}
@ -1201,9 +1205,11 @@ where
// catch cancelled notification
let mut notification = match notification.try_into() {
Ok::<CancelledNotification, _>(cancelled) => {
if let Some(ct) = local_ct_pool.remove(&cancelled.params.request_id) {
tracing::info!(id = %cancelled.params.request_id, reason = cancelled.params.reason, "cancelled");
ct.cancel();
if let Some(request_id) = &cancelled.params.request_id {
if let Some(ct) = local_ct_pool.remove(request_id) {
tracing::info!(id = %request_id, reason = cancelled.params.reason, "cancelled");
ct.cancel();
}
}
cancelled.into()
}

View file

@ -9,8 +9,8 @@ use url::Url;
use super::*;
#[cfg(feature = "elicitation")]
use crate::model::{
CreateElicitationRequest, CreateElicitationRequestParams, CreateElicitationResult,
ElicitationAction, ElicitationCompletionNotification, ElicitationResponseNotificationParam,
ElicitRequest, ElicitRequestParams, ElicitResult, ElicitationAction,
ElicitationCompleteNotification, ElicitationResponseNotificationParam,
};
use crate::{
model::{
@ -464,11 +464,11 @@ impl Peer<RoleServer> {
peer_req list_roots ListRootsRequest() => ListRootsResult
);
#[cfg(feature = "elicitation")]
method!(peer_req create_elicitation CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult);
method!(peer_req create_elicitation ElicitRequest(ElicitRequestParams) => ElicitResult);
#[cfg(feature = "elicitation")]
method!(peer_req_with_timeout create_elicitation_with_timeout CreateElicitationRequest(CreateElicitationRequestParams) => CreateElicitationResult);
method!(peer_req_with_timeout create_elicitation_with_timeout ElicitRequest(ElicitRequestParams) => ElicitResult);
#[cfg(feature = "elicitation")]
method!(peer_not notify_url_elicitation_completed ElicitationCompletionNotification(ElicitationResponseNotificationParam));
method!(peer_not notify_url_elicitation_completed ElicitationCompleteNotification(ElicitationResponseNotificationParam));
method!(peer_not notify_cancelled CancelledNotification(CancelledNotificationParam));
method!(peer_not notify_progress ProgressNotification(ProgressNotificationParam));
@ -787,7 +787,7 @@ impl Peer<RoleServer> {
let response = self
.create_elicitation_with_timeout(
CreateElicitationRequestParams::FormElicitationParams {
ElicitRequestParams::FormElicitationParams {
meta: None,
message: message.into(),
requested_schema: schema,
@ -920,7 +920,7 @@ impl Peer<RoleServer> {
let action = self
.create_elicitation_with_timeout(
CreateElicitationRequestParams::UrlElicitationParams {
ElicitRequestParams::UrlElicitationParams {
meta: None,
message: message.into(),
url: url.into().to_string(),

View file

@ -429,9 +429,10 @@ impl LocalSessionWorker {
notification: &JsonRpcNotification<ClientNotification>,
) {
if let ClientNotification::CancelledNotification(n) = &notification.notification {
let request_id = n.params.request_id.clone();
let resource = ResourceKey::McpRequestId(request_id);
self.unregister_resource(&resource);
if let Some(request_id) = n.params.request_id.clone() {
let resource = ResourceKey::McpRequestId(request_id);
self.unregister_resource(&resource);
}
}
}
fn evict_expired_channels(&mut self) {
@ -496,13 +497,17 @@ impl LocalSessionWorker {
}),
..
}) => {
if let Some(id) = self
.resource_router
.get(&ResourceKey::McpRequestId(request_id.clone()))
{
OutboundChannel::RequestWise {
id: *id,
close: false,
if let Some(req_id) = request_id {
if let Some(id) = self
.resource_router
.get(&ResourceKey::McpRequestId(req_id.clone()))
{
OutboundChannel::RequestWise {
id: *id,
close: false,
}
} else {
OutboundChannel::Common
}
} else {
OutboundChannel::Common

Some files were not shown because too many files have changed in this diff Show more