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
54 changes: 54 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/ContentBlock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@ public sealed class Converter : JsonConverter<ContentBlock>
string? type = null;
string? text = null;
string? name = null;
string? title = null;
ReadOnlyMemory<byte>? data = null;
string? mimeType = null;
string? uri = null;
string? description = null;
long? size = null;
IList<Icon>? icons = null;
ResourceContents? resource = null;
Annotations? annotations = null;
JsonObject? meta = null;
Expand Down Expand Up @@ -130,6 +132,10 @@ public sealed class Converter : JsonConverter<ContentBlock>
name = reader.GetString();
break;

case "title":
title = reader.GetString();
break;

case "data":
data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
break;
Expand All @@ -150,6 +156,18 @@ public sealed class Converter : JsonConverter<ContentBlock>
size = reader.GetInt64();
break;

case "icons":
if (reader.TokenType == JsonTokenType.StartArray)
{
icons = [];
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
icons.Add(JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.Icon) ??
throw new JsonException("Unexpected null item in icons array."));
}
}
break;

case "resource":
resource = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.ResourceContents);
break;
Expand Down Expand Up @@ -233,9 +251,11 @@ public sealed class Converter : JsonConverter<ContentBlock>
{
Uri = uri ?? throw new JsonException("URI must be provided for 'resource_link' type."),
Name = name ?? throw new JsonException("Name must be provided for 'resource_link' type."),
Title = title,
Description = description,
MimeType = mimeType,
Size = size,
Icons = icons,
},

"tool_use" => new ToolUseContentBlock
Expand Down Expand Up @@ -299,6 +319,10 @@ public override void Write(Utf8JsonWriter writer, ContentBlock value, JsonSerial
case ResourceLinkBlock resourceLink:
writer.WriteString("uri", resourceLink.Uri);
writer.WriteString("name", resourceLink.Name);
if (resourceLink.Title is not null)
{
writer.WriteString("title", resourceLink.Title);
}
if (resourceLink.Description is not null)
{
writer.WriteString("description", resourceLink.Description);
Expand All @@ -311,6 +335,16 @@ public override void Write(Utf8JsonWriter writer, ContentBlock value, JsonSerial
{
writer.WriteNumber("size", resourceLink.Size.Value);
}
if (resourceLink.Icons is { Count: > 0 })
{
writer.WritePropertyName("icons");
writer.WriteStartArray();
foreach (var icon in resourceLink.Icons)
{
JsonSerializer.Serialize(writer, icon, McpJsonUtilities.JsonContext.Default.Icon);
}
writer.WriteEndArray();
}
break;

case ToolUseContentBlock toolUse:
Expand Down Expand Up @@ -595,6 +629,17 @@ public sealed class ResourceLinkBlock : ContentBlock
[JsonPropertyName("name")]
public required string Name { get; set; }

/// <summary>
/// Gets or sets a title for this resource.
/// </summary>
/// <remarks>
/// This is intended for UI and end-user contexts. It is optimized to be human-readable and easily understood,
/// even by those unfamiliar with domain-specific terminology.
/// If not provided, <see cref="Name"/> can be used for display.
/// </remarks>
[JsonPropertyName("title")]
public string? Title { get; set; }

/// <summary>
/// Gets or sets a description of what this resource represents.
/// </summary>
Expand Down Expand Up @@ -638,6 +683,15 @@ public sealed class ResourceLinkBlock : ContentBlock
/// </remarks>
[JsonPropertyName("size")]
public long? Size { get; set; }

/// <summary>
/// Gets or sets an optional list of icons for this resource.
/// </summary>
/// <remarks>
/// This can be used by clients to display the resource's icon in a user interface.
/// </remarks>
[JsonPropertyName("icons")]
public IList<Icon>? Icons { get; set; }
}

/// <summary>Represents a request from the assistant to call a tool.</summary>
Expand Down
49 changes: 48 additions & 1 deletion tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ public void ResourceLinkBlock_SerializationRoundTrip_PreservesAllProperties()
{
Uri = "https://example.com/resource",
Name = "Test Resource",
Title = "Test Resource Title",
Description = "A test resource for validation",
MimeType = "text/plain",
Size = 1024
Size = 1024,
Icons = [new Icon { Source = "https://example.com/icon.png", MimeType = "image/png" }]
};

// Act - Serialize to JSON
Expand All @@ -30,10 +32,15 @@ public void ResourceLinkBlock_SerializationRoundTrip_PreservesAllProperties()

Assert.Equal(original.Uri, resourceLink.Uri);
Assert.Equal(original.Name, resourceLink.Name);
Assert.Equal(original.Title, resourceLink.Title);
Assert.Equal(original.Description, resourceLink.Description);
Assert.Equal(original.MimeType, resourceLink.MimeType);
Assert.Equal(original.Size, resourceLink.Size);
Assert.Equal("resource_link", resourceLink.Type);
Assert.NotNull(resourceLink.Icons);
Assert.Single(resourceLink.Icons);
Assert.Equal("https://example.com/icon.png", resourceLink.Icons[0].Source);
Assert.Equal("image/png", resourceLink.Icons[0].MimeType);
}

[Fact]
Expand All @@ -57,9 +64,11 @@ public void ResourceLinkBlock_DeserializationWithMinimalProperties_Succeeds()

Assert.Equal("https://example.com/minimal", resourceLink.Uri);
Assert.Equal("Minimal Resource", resourceLink.Name);
Assert.Null(resourceLink.Title);
Assert.Null(resourceLink.Description);
Assert.Null(resourceLink.MimeType);
Assert.Null(resourceLink.Size);
Assert.Null(resourceLink.Icons);
Assert.Equal("resource_link", resourceLink.Type);
}

Expand All @@ -81,6 +90,44 @@ public void ResourceLinkBlock_DeserializationWithoutName_ThrowsJsonException()
Assert.Contains("Name must be provided for 'resource_link' type", exception.Message);
}

[Fact]
public void ResourceLinkBlock_DeserializationWithTitleAndIcons_Succeeds()
{
// Arrange - JSON with title and icons properties per spec
const string Json = """
{
"type": "resource_link",
"uri": "https://example.com/resource",
"name": "my-resource",
"title": "My Resource",
"icons": [
{ "src": "https://example.com/icon1.png", "mimeType": "image/png", "sizes": ["48x48"], "theme": "light" },
{ "src": "https://example.com/icon2.svg", "mimeType": "image/svg+xml" }
]
}
""";

// Act
var deserialized = JsonSerializer.Deserialize<ContentBlock>(Json, McpJsonUtilities.DefaultOptions);

// Assert
Assert.NotNull(deserialized);
var resourceLink = Assert.IsType<ResourceLinkBlock>(deserialized);

Assert.Equal("https://example.com/resource", resourceLink.Uri);
Assert.Equal("my-resource", resourceLink.Name);
Assert.Equal("My Resource", resourceLink.Title);
Assert.NotNull(resourceLink.Icons);
Assert.Equal(2, resourceLink.Icons.Count);
Assert.Equal("https://example.com/icon1.png", resourceLink.Icons[0].Source);
Assert.Equal("image/png", resourceLink.Icons[0].MimeType);
Assert.NotNull(resourceLink.Icons[0].Sizes);
Assert.Equal("48x48", resourceLink.Icons[0].Sizes![0]);
Assert.Equal("light", resourceLink.Icons[0].Theme);
Assert.Equal("https://example.com/icon2.svg", resourceLink.Icons[1].Source);
Assert.Equal("image/svg+xml", resourceLink.Icons[1].MimeType);
}

[Fact]
public void Deserialize_IgnoresUnknownArrayProperty()
{
Expand Down