Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions agent-client-protocol-schema/src/v2/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2990,6 +2990,7 @@ pub struct McpServerHttp {
/// Human-readable name identifying this MCP server.
pub name: String,
/// URL to the MCP server.
#[schemars(url)]
pub url: String,
/// HTTP headers to set when making requests to the MCP server.
#[serde_as(deserialize_as = "DefaultOnError<VecSkipError<_, SkipListener>>")]
Expand Down Expand Up @@ -6007,6 +6008,13 @@ mod test_serialization {
}
}

#[test]
fn mcp_server_http_schema_marks_url_as_uri() {
let schema = serde_json::to_value(schemars::schema_for!(McpServerHttp)).unwrap();

assert_eq!(schema["properties"]["url"]["format"], "uri");
}

#[cfg(feature = "unstable_mcp_over_acp")]
#[test]
fn test_client_mcp_message_method_names() {
Expand Down
159 changes: 159 additions & 0 deletions agent-client-protocol-schema/src/v2/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,12 +212,14 @@ impl<T: Into<String>> From<T> for ContentBlock {
#[non_exhaustive]
pub struct ImageContent {
/// Base64-encoded media payload.
#[schemars(extend("format" = "byte"))]
pub data: String,
/// MIME type describing the encoded media payload.
pub mime_type: String,
/// URI associated with this resource or media payload.
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[schemars(url)]
#[serde(default)]
pub uri: Option<String>,
/// Optional annotations that help clients decide how to display or route this content.
Expand Down Expand Up @@ -284,6 +286,7 @@ impl ImageContent {
#[non_exhaustive]
pub struct AudioContent {
/// Base64-encoded media payload.
#[schemars(extend("format" = "byte"))]
pub data: String,
/// MIME type describing the encoded media payload.
pub mime_type: String,
Expand Down Expand Up @@ -411,6 +414,7 @@ pub struct TextResourceContents {
/// Text payload carried by this content block.
pub text: String,
/// URI associated with this resource or media payload.
#[schemars(url)]
pub uri: String,
/// MIME type describing the encoded media payload.
#[serde_as(deserialize_as = "DefaultOnError")]
Expand Down Expand Up @@ -468,8 +472,10 @@ impl TextResourceContents {
#[non_exhaustive]
pub struct BlobResourceContents {
/// Base64-encoded bytes for a binary resource payload.
#[schemars(extend("format" = "byte"))]
pub blob: String,
/// URI associated with this resource or media payload.
#[schemars(url)]
pub uri: String,
/// MIME type describing the encoded media payload.
#[serde_as(deserialize_as = "DefaultOnError")]
Expand Down Expand Up @@ -529,6 +535,7 @@ pub struct ResourceLink {
/// Human-readable name shown for this protocol object.
pub name: String,
/// URI associated with this resource or media payload.
#[schemars(url)]
pub uri: String,
/// Optional display title for end-user UI.
#[serde_as(deserialize_as = "DefaultOnError")]
Expand All @@ -540,6 +547,11 @@ pub struct ResourceLink {
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub description: Option<String>,
/// Optional set of sized icons that the client can display in a user interface.
#[serde_as(deserialize_as = "DefaultOnError<Option<VecSkipError<_, SkipListener>>>")]
#[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
#[serde(default)]
pub icons: Option<Vec<Icon>>,
/// MIME type describing the encoded media payload.
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
Expand Down Expand Up @@ -574,6 +586,7 @@ impl ResourceLink {
Self {
annotations: None,
description: None,
icons: None,
mime_type: None,
name: name.into(),
size: None,
Expand All @@ -597,6 +610,13 @@ impl ResourceLink {
self
}

/// Sets or clears the optional `icons` field.
#[must_use]
pub fn icons(mut self, icons: impl IntoOption<Vec<Icon>>) -> Self {
self.icons = icons.into_option();
self
}

/// Sets or clears the optional `mimeType` field.
#[must_use]
pub fn mime_type(mut self, mime_type: impl IntoOption<String>) -> Self {
Expand Down Expand Up @@ -630,6 +650,85 @@ impl ResourceLink {
}
}

/// An optionally-sized icon that can be displayed in a user interface.
#[serde_as]
#[skip_serializing_none]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct Icon {
/// A standard URI pointing to an icon resource.
#[schemars(url)]
pub src: String,
/// Optional MIME type override if the source MIME type is missing or generic.
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub mime_type: Option<String>,
/// Optional sizes at which the icon can be used.
#[serde_as(deserialize_as = "DefaultOnError<Option<VecSkipError<_, SkipListener>>>")]
#[schemars(extend("x-deserialize-default-on-error" = true, "x-deserialize-skip-invalid-items" = true))]
#[serde(default)]
pub sizes: Option<Vec<String>>,
/// Optional theme this icon is designed for.
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[serde(default)]
pub theme: Option<IconTheme>,
}

impl Icon {
/// Builds [`Icon`] with the required source URI; optional display hints start unset.
#[must_use]
pub fn new(src: impl Into<String>) -> Self {
Self {
src: src.into(),
mime_type: None,
sizes: None,
theme: None,
}
}

/// Sets or clears the optional `mimeType` field.
#[must_use]
pub fn mime_type(mut self, mime_type: impl IntoOption<String>) -> Self {
self.mime_type = mime_type.into_option();
self
}

/// Sets or clears the optional `sizes` field.
#[must_use]
pub fn sizes(mut self, sizes: impl IntoOption<Vec<String>>) -> Self {
self.sizes = sizes.into_option();
self
}

/// Sets or clears the optional `theme` field.
#[must_use]
pub fn theme(mut self, theme: impl IntoOption<IconTheme>) -> Self {
self.theme = theme.into_option();
self
}
}

/// Theme an icon is designed for.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub enum IconTheme {
/// Icon designed for light backgrounds.
Light,
/// Icon designed for dark backgrounds.
Dark,
/// Custom or future icon theme.
///
/// Values beginning with `_` are reserved for implementation-specific
/// extensions. Unknown values that do not begin with `_` are reserved for
/// future ACP variants.
#[serde(untagged)]
Other(String),
}

/// Optional annotations for the client. The client can use annotations to inform how objects are used or displayed
#[serde_as]
#[skip_serializing_none]
Expand All @@ -650,6 +749,7 @@ pub struct Annotations {
/// Relative importance of this content when clients choose what to surface.
#[serde_as(deserialize_as = "DefaultOnError")]
#[schemars(extend("x-deserialize-default-on-error" = true))]
#[schemars(range(min = 0, max = 1))]
#[serde(default)]
pub priority: Option<f64>,
/// The _meta property is reserved by ACP to allow clients and agents to attach additional
Expand Down Expand Up @@ -774,6 +874,13 @@ mod tests {
assert_eq!(serde_json::to_value(&role).unwrap(), "critic");
}

#[test]
fn icon_theme_preserves_unknown_variant() {
let theme: IconTheme = serde_json::from_str("\"contrast\"").unwrap();
assert_eq!(theme, IconTheme::Other("contrast".to_string()));
assert_eq!(serde_json::to_value(&theme).unwrap(), "contrast");
}

#[test]
fn content_block_preserves_unknown_variant() {
let block: ContentBlock = serde_json::from_value(serde_json::json!({
Expand Down Expand Up @@ -851,4 +958,56 @@ mod tests {
assert!(!json.as_object().unwrap().contains_key("annotations"));
assert!(!json.as_object().unwrap().contains_key("meta"));
}

#[test]
fn resource_link_icons_roundtrip() {
let icon = Icon::new("https://example.com/icon.png")
.mime_type("image/png")
.sizes(vec!["48x48".to_string(), "any".to_string()])
.theme(IconTheme::Dark);
let link = ResourceLink::new("Example", "file:///example.txt").icons(vec![icon]);

let json = serde_json::to_value(&link).unwrap();
assert_eq!(json["icons"][0]["src"], "https://example.com/icon.png");
assert_eq!(json["icons"][0]["mimeType"], "image/png");
assert_eq!(json["icons"][0]["sizes"][0], "48x48");
assert_eq!(json["icons"][0]["theme"], "dark");

let parsed: ResourceLink = serde_json::from_value(json).unwrap();
assert_eq!(link, parsed);
}

#[test]
fn annotations_priority_schema_matches_mcp_bounds() {
let schema = schemars::schema_for!(Annotations);
let json = serde_json::to_value(schema).unwrap();

assert_eq!(json["properties"]["priority"]["minimum"], 0);
assert_eq!(json["properties"]["priority"]["maximum"], 1);
}

#[test]
fn content_schema_matches_mcp_string_formats() {
let image = serde_json::to_value(schemars::schema_for!(ImageContent)).unwrap();
assert_eq!(image["properties"]["data"]["format"], "byte");
assert_eq!(image["properties"]["uri"]["format"], "uri");

let audio = serde_json::to_value(schemars::schema_for!(AudioContent)).unwrap();
assert_eq!(audio["properties"]["data"]["format"], "byte");

let text_resource =
serde_json::to_value(schemars::schema_for!(TextResourceContents)).unwrap();
assert_eq!(text_resource["properties"]["uri"]["format"], "uri");

let blob_resource =
serde_json::to_value(schemars::schema_for!(BlobResourceContents)).unwrap();
assert_eq!(blob_resource["properties"]["blob"]["format"], "byte");
assert_eq!(blob_resource["properties"]["uri"]["format"], "uri");

let resource_link = serde_json::to_value(schemars::schema_for!(ResourceLink)).unwrap();
assert_eq!(resource_link["properties"]["uri"]["format"], "uri");

let icon = serde_json::to_value(schemars::schema_for!(Icon)).unwrap();
assert_eq!(icon["properties"]["src"]["format"], "uri");
}
}
28 changes: 23 additions & 5 deletions agent-client-protocol-schema/src/v2/conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8912,13 +8912,21 @@ impl IntoV1 for super::ResourceLink {
let Self {
annotations,
description,
icons,
mime_type,
name,
size,
title,
uri,
meta,
} = self;

if matches!(icons.as_ref(), Some(icons) if !icons.is_empty()) {
return Err(ProtocolConversionError::new(
"v2 ResourceLink.icons cannot be represented in v1",
));
}

Ok(crate::v1::ResourceLink {
annotations: into_v1_default_on_error(annotations),
description: description.into_v1()?,
Expand Down Expand Up @@ -8949,6 +8957,7 @@ impl IntoV2 for crate::v1::ResourceLink {
Ok(super::ResourceLink {
annotations: into_v2_default_on_error(annotations),
description: description.into_v2()?,
icons: None,
mime_type: mime_type.into_v2()?,
name: name.into_v2()?,
size: size.into_v2()?,
Expand Down Expand Up @@ -9208,16 +9217,16 @@ mod tests {

#[test]
fn round_trips_initialize_request() {
let mut client_capabilities = v1::ClientCapabilities::new();
let client_capabilities = v1::ClientCapabilities::new();
#[cfg(feature = "unstable_boolean_config")]
{
client_capabilities = client_capabilities.session(
let client_capabilities = {
client_capabilities.session(
v1::ClientSessionCapabilities::new().config_options(
v1::SessionConfigOptionsCapabilities::new()
.boolean(v1::BooleanConfigOptionCapabilities::new()),
),
);
}
)
};

let request = v1::InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(client_capabilities)
Expand Down Expand Up @@ -10450,6 +10459,15 @@ mod tests {
);
}

#[test]
fn v2_resource_link_icons_do_not_convert_to_v1() {
assert_v2_to_v1_error(
v2::ResourceLink::new("file.txt", "file:///file.txt")
.icons(vec![v2::Icon::new("https://example.com/icon.png")]),
"v2 ResourceLink.icons cannot be represented in v1",
);
}

#[test]
fn unknown_v2_raw_fallbacks_do_not_convert_to_v1() {
assert_v2_to_v1_error(
Expand Down
Loading
Loading