Skip to content

Commit 4be62c5

Browse files
Copilotstephentoub
andcommitted
Make CancelledNotificationParams.RequestId optional and add Title/Icons to ResourceLinkBlock per 2025-11-25 spec
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
1 parent 0006d8b commit 4be62c5

6 files changed

Lines changed: 74 additions & 6 deletions

File tree

src/ModelContextProtocol.Core/McpJsonUtilities.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
169169
[JsonSerializable(typeof(ResourceLinkBlock))]
170170
[JsonSerializable(typeof(ContentBlock[]))]
171171
[JsonSerializable(typeof(IEnumerable<ContentBlock>))]
172+
[JsonSerializable(typeof(IList<Icon>))]
172173
[JsonSerializable(typeof(PromptMessage))]
173174
[JsonSerializable(typeof(IEnumerable<PromptMessage>))]
174175
[JsonSerializable(typeof(PromptReference))]

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -381,10 +381,11 @@ private async Task HandleNotificationAsync(JsonRpcNotification notification, Can
381381
try
382382
{
383383
if (GetCancelledNotificationParams(notification.Params) is CancelledNotificationParams cn &&
384-
_handlingRequests.TryGetValue(cn.RequestId, out var cts))
384+
cn.RequestId is RequestId requestId &&
385+
_handlingRequests.TryGetValue(requestId, out var cts))
385386
{
386387
await cts.CancelAsync().ConfigureAwait(false);
387-
LogRequestCanceled(EndpointName, cn.RequestId, cn.Reason);
388+
LogRequestCanceled(EndpointName, requestId, cn.Reason);
388389
}
389390
}
390391
catch
@@ -626,7 +627,8 @@ await _outgoingMessageFilter(async (msg, ct) =>
626627
// race conditions here, so it's possible and allowed for the operation to complete before we get to this point.
627628
if (msg is JsonRpcNotification { Method: NotificationMethods.CancelledNotification } notification &&
628629
GetCancelledNotificationParams(notification.Params) is CancelledNotificationParams cn &&
629-
_pendingRequests.TryRemove(cn.RequestId, out var tcs))
630+
cn.RequestId is RequestId cancelledRequestId &&
631+
_pendingRequests.TryRemove(cancelledRequestId, out var tcs))
630632
{
631633
tcs.TrySetCanceled(default);
632634
}

src/ModelContextProtocol.Core/Protocol/CancelledNotificationParams.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,14 @@ public sealed class CancelledNotificationParams : NotificationParams
1717
/// Gets or sets the ID of the request to cancel.
1818
/// </summary>
1919
/// <remarks>
20-
/// This value must match the ID of an in-flight request that the sender wishes to cancel.
20+
/// <para>
21+
/// This must correspond to the ID of a request previously issued in the same direction.
22+
/// This must be provided for cancelling non-task requests.
23+
/// This must not be used for cancelling tasks (use the <c>tasks/cancel</c> request instead).
24+
/// </para>
2125
/// </remarks>
2226
[JsonPropertyName("requestId")]
23-
public required RequestId RequestId { get; set; }
27+
public RequestId? RequestId { get; set; }
2428

2529
/// <summary>
2630
/// Gets or sets an optional string describing the reason for the cancellation request.

src/ModelContextProtocol.Core/Protocol/ContentBlock.cs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,13 @@ public sealed class Converter : JsonConverter<ContentBlock>
9090
string? type = null;
9191
string? text = null;
9292
string? name = null;
93+
string? title = null;
9394
ReadOnlyMemory<byte>? data = null;
9495
string? mimeType = null;
9596
string? uri = null;
9697
string? description = null;
9798
long? size = null;
99+
IList<Icon>? icons = null;
98100
ResourceContents? resource = null;
99101
Annotations? annotations = null;
100102
JsonObject? meta = null;
@@ -130,6 +132,10 @@ public sealed class Converter : JsonConverter<ContentBlock>
130132
name = reader.GetString();
131133
break;
132134

135+
case "title":
136+
title = reader.GetString();
137+
break;
138+
133139
case "data":
134140
data = reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray();
135141
break;
@@ -150,6 +156,10 @@ public sealed class Converter : JsonConverter<ContentBlock>
150156
size = reader.GetInt64();
151157
break;
152158

159+
case "icons":
160+
icons = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.IListIcon);
161+
break;
162+
153163
case "resource":
154164
resource = JsonSerializer.Deserialize(ref reader, McpJsonUtilities.JsonContext.Default.ResourceContents);
155165
break;
@@ -233,9 +243,11 @@ public sealed class Converter : JsonConverter<ContentBlock>
233243
{
234244
Uri = uri ?? throw new JsonException("URI must be provided for 'resource_link' type."),
235245
Name = name ?? throw new JsonException("Name must be provided for 'resource_link' type."),
246+
Title = title,
236247
Description = description,
237248
MimeType = mimeType,
238249
Size = size,
250+
Icons = icons,
239251
},
240252

241253
"tool_use" => new ToolUseContentBlock
@@ -299,6 +311,10 @@ public override void Write(Utf8JsonWriter writer, ContentBlock value, JsonSerial
299311
case ResourceLinkBlock resourceLink:
300312
writer.WriteString("uri", resourceLink.Uri);
301313
writer.WriteString("name", resourceLink.Name);
314+
if (resourceLink.Title is not null)
315+
{
316+
writer.WriteString("title", resourceLink.Title);
317+
}
302318
if (resourceLink.Description is not null)
303319
{
304320
writer.WriteString("description", resourceLink.Description);
@@ -311,6 +327,11 @@ public override void Write(Utf8JsonWriter writer, ContentBlock value, JsonSerial
311327
{
312328
writer.WriteNumber("size", resourceLink.Size.Value);
313329
}
330+
if (resourceLink.Icons is { Count: > 0 } resourceLinkIcons)
331+
{
332+
writer.WritePropertyName("icons");
333+
JsonSerializer.Serialize(writer, resourceLinkIcons, McpJsonUtilities.JsonContext.Default.IListIcon);
334+
}
314335
break;
315336

316337
case ToolUseContentBlock toolUse:
@@ -595,6 +616,15 @@ public sealed class ResourceLinkBlock : ContentBlock
595616
[JsonPropertyName("name")]
596617
public required string Name { get; set; }
597618

619+
/// <summary>
620+
/// Gets or sets an optional human-readable title for this resource, intended for UI display purposes.
621+
/// </summary>
622+
/// <remarks>
623+
/// If not provided, the <see cref="Name"/> should be used for display.
624+
/// </remarks>
625+
[JsonPropertyName("title")]
626+
public string? Title { get; set; }
627+
598628
/// <summary>
599629
/// Gets or sets a description of what this resource represents.
600630
/// </summary>
@@ -638,6 +668,12 @@ public sealed class ResourceLinkBlock : ContentBlock
638668
/// </remarks>
639669
[JsonPropertyName("size")]
640670
public long? Size { get; set; }
671+
672+
/// <summary>
673+
/// Gets or sets optional icons for this resource, for display in user interfaces.
674+
/// </summary>
675+
[JsonPropertyName("icons")]
676+
public IList<Icon>? Icons { get; set; }
641677
}
642678

643679
/// <summary>Represents a request from the assistant to call a tool.</summary>

tests/ModelContextProtocol.Tests/Protocol/CancelledNotificationParamsTests.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,20 @@ public static void CancelledNotificationParams_SerializationRoundTrip_WithMinima
4242
Assert.Null(deserialized.Reason);
4343
Assert.Null(deserialized.Meta);
4444
}
45+
46+
[Fact]
47+
public static void CancelledNotificationParams_SerializationRoundTrip_WithoutRequestId()
48+
{
49+
var original = new CancelledNotificationParams
50+
{
51+
Reason = "Task cancelled"
52+
};
53+
54+
string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
55+
var deserialized = JsonSerializer.Deserialize<CancelledNotificationParams>(json, McpJsonUtilities.DefaultOptions);
56+
57+
Assert.NotNull(deserialized);
58+
Assert.Null(deserialized.RequestId);
59+
Assert.Equal(original.Reason, deserialized.Reason);
60+
}
4561
}

tests/ModelContextProtocol.Tests/Protocol/ContentBlockTests.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ public void ResourceLinkBlock_SerializationRoundTrip_PreservesAllProperties()
1313
{
1414
Uri = "https://example.com/resource",
1515
Name = "Test Resource",
16+
Title = "Test Resource Display Title",
1617
Description = "A test resource for validation",
1718
MimeType = "text/plain",
18-
Size = 1024
19+
Size = 1024,
20+
Icons = [new Icon { Source = "https://example.com/icon.png", MimeType = "image/png" }]
1921
};
2022

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

3133
Assert.Equal(original.Uri, resourceLink.Uri);
3234
Assert.Equal(original.Name, resourceLink.Name);
35+
Assert.Equal(original.Title, resourceLink.Title);
3336
Assert.Equal(original.Description, resourceLink.Description);
3437
Assert.Equal(original.MimeType, resourceLink.MimeType);
3538
Assert.Equal(original.Size, resourceLink.Size);
3639
Assert.Equal("resource_link", resourceLink.Type);
40+
Assert.NotNull(resourceLink.Icons);
41+
Assert.Single(resourceLink.Icons);
42+
Assert.Equal("https://example.com/icon.png", resourceLink.Icons[0].Source);
43+
Assert.Equal("image/png", resourceLink.Icons[0].MimeType);
3744
}
3845

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

5865
Assert.Equal("https://example.com/minimal", resourceLink.Uri);
5966
Assert.Equal("Minimal Resource", resourceLink.Name);
67+
Assert.Null(resourceLink.Title);
6068
Assert.Null(resourceLink.Description);
6169
Assert.Null(resourceLink.MimeType);
6270
Assert.Null(resourceLink.Size);
71+
Assert.Null(resourceLink.Icons);
6372
Assert.Equal("resource_link", resourceLink.Type);
6473
}
6574

0 commit comments

Comments
 (0)