Skip to content

Commit be6cd29

Browse files
Support multiple contents in sampling results.
1 parent ab6d3e1 commit be6cd29

12 files changed

Lines changed: 159 additions & 15 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClient.Methods.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -623,7 +623,7 @@ internal static CreateMessageResult ToCreateMessageResult(ChatResponse chatRespo
623623

624624
return new()
625625
{
626-
Content = content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty },
626+
Contents = [content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty }],
627627
Model = chatResponse.ModelId ?? "unknown",
628628
Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant,
629629
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",

src/ModelContextProtocol.Core/Client/McpClientExtensions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,7 @@ internal static CreateMessageResult ToCreateMessageResult(this ChatResponse chat
703703

704704
return new()
705705
{
706-
Content = content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty },
706+
Contents = [content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty }],
707707
Model = chatResponse.ModelId ?? "unknown",
708708
Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant,
709709
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using System.ComponentModel;
2+
using System.Text.Json;
3+
using System.Text.Json.Nodes;
4+
using System.Text.Json.Serialization;
5+
using System.Text.Json.Serialization.Metadata;
6+
7+
namespace ModelContextProtocol;
8+
9+
public static partial class McpJsonUtilities
10+
{
11+
[ThreadStatic]
12+
private static McpSession? t_currentMcpSession;
13+
14+
/// <summary>
15+
/// Serializes the given value to a <see cref="JsonNode"/> using the provided <see cref="JsonTypeInfo{T}"/>,
16+
/// </summary>
17+
internal static JsonNode? SerializeContextual<T>(T? value, JsonTypeInfo<T> typeInfo, McpSession session)
18+
{
19+
if (session is null)
20+
{
21+
Throw.IfNull(session);
22+
}
23+
24+
if (t_currentMcpSession is not null)
25+
{
26+
throw new InvalidOperationException("Reentrant call to McpJsonUtilities.SerializeContextual detected.");
27+
}
28+
29+
t_currentMcpSession = session;
30+
try
31+
{
32+
return JsonSerializer.SerializeToNode(value!, typeInfo);
33+
}
34+
finally
35+
{
36+
t_currentMcpSession = null;
37+
}
38+
}
39+
40+
/// <summary>
41+
/// Deserializes the given value to a <see cref="JsonNode"/> using the provided <see cref="JsonTypeInfo{T}"/>,
42+
/// </summary>
43+
internal static T? DeserializeContextual<T>(JsonNode? node, JsonTypeInfo<T> typeInfo, McpSession session)
44+
{
45+
if (session is null)
46+
{
47+
Throw.IfNull(session);
48+
}
49+
50+
if (t_currentMcpSession is not null)
51+
{
52+
throw new InvalidOperationException("Reentrant call to McpJsonUtilities.DeserializeContextual detected.");
53+
}
54+
55+
t_currentMcpSession = session;
56+
try
57+
{
58+
return JsonSerializer.Deserialize(node, typeInfo);
59+
}
60+
finally
61+
{
62+
t_currentMcpSession = null;
63+
}
64+
}
65+
66+
/// <summary>
67+
/// Defines an abstract JSON converter that has access to the current <see cref="IMcpEndpoint"/> context during serialization and deserialization.
68+
/// </summary>
69+
/// <typeparam name="T">The type being converted.</typeparam>
70+
[EditorBrowsable(EditorBrowsableState.Never)]
71+
public abstract class McpContextualJsonConverter<T> : JsonConverter<T>
72+
{
73+
/// <summary>
74+
/// Reads the JSON representation of the value.
75+
/// </summary>
76+
public abstract T? Read(ref Utf8JsonReader reader, McpSession? session, JsonSerializerOptions options);
77+
78+
/// <summary>
79+
/// Writes the JSON representation of the value.
80+
/// </summary>
81+
public abstract void Write(Utf8JsonWriter writer, T value, McpSession? session, JsonSerializerOptions options);
82+
83+
/// <inheritdoc/>
84+
public sealed override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
85+
Read(ref reader, t_currentMcpSession, options);
86+
87+
/// <inheritdoc/>
88+
public sealed override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) =>
89+
Write(writer, value, t_currentMcpSession, options);
90+
}
91+
}

src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using System.ComponentModel;
2+
using System.Text.Json;
13
using System.Text.Json.Serialization;
24

35
namespace ModelContextProtocol.Protocol;
@@ -14,7 +16,26 @@ public sealed class CreateMessageResult : Result
1416
/// Gets or sets the content of the message.
1517
/// </summary>
1618
[JsonPropertyName("content")]
17-
public required ContentBlock Content { get; init; }
19+
[JsonConverter(typeof(SingleOrArrayContentConverter))]
20+
public required List<ContentBlock> Contents
21+
{
22+
get;
23+
init
24+
{
25+
if (value is null or [])
26+
{
27+
throw new ArgumentException(nameof(Contents));
28+
}
29+
30+
field = value;
31+
}
32+
}
33+
34+
/// <summary>
35+
/// Gets or sets the content of the message.
36+
/// </summary>
37+
[JsonIgnore]
38+
public ContentBlock Content { get => Contents.First(); init => Contents = [value]; }
1839

1940
/// <summary>
2041
/// Gets or sets the name of the model that generated the message.
@@ -50,4 +71,36 @@ public sealed class CreateMessageResult : Result
5071
/// </summary>
5172
[JsonPropertyName("role")]
5273
public required Role Role { get; init; }
74+
75+
/// <summary>
76+
/// Defines a converter that handles deserialization of a single <see cref="ContentBlock"/> or an array of <see cref="ContentBlock"/> into a <see cref="List{ContentBlock}"/>.
77+
/// </summary>
78+
[EditorBrowsable(EditorBrowsableState.Never)]
79+
public sealed class SingleOrArrayContentConverter : McpJsonUtilities.McpContextualJsonConverter<List<ContentBlock>>
80+
{
81+
/// <inheritdoc/>
82+
public override List<ContentBlock>? Read(ref Utf8JsonReader reader, McpSession? session, JsonSerializerOptions options)
83+
{
84+
if (reader.TokenType is JsonTokenType.StartObject)
85+
{
86+
var single = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<ContentBlock>());
87+
return [single];
88+
}
89+
90+
return JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<List<ContentBlock>>());
91+
}
92+
93+
/// <inheritdoc/>
94+
public override void Write(Utf8JsonWriter writer, List<ContentBlock> value, McpSession? session, JsonSerializerOptions options)
95+
{
96+
if (session?.NegotiatedProtocolVersion is string version &&
97+
DateTime.Parse(version) < new DateTime(2025, 09, 18)) // A hypothetical future version
98+
{
99+
// The negotiated protocol version is before 2025-09-18, so we need to serialize as a single object.
100+
JsonSerializer.Serialize(value.Single(), options.GetTypeInfo<ContentBlock>());
101+
}
102+
103+
JsonSerializer.Serialize(value, options.GetTypeInfo<List<ContentBlock>>());
104+
}
105+
}
53106
}

tests/Common/Utils/TestServerTransport.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ private async Task SamplingAsync(JsonRpcRequest request, CancellationToken cance
7474
await WriteMessageAsync(new JsonRpcResponse
7575
{
7676
Id = request.Id,
77-
Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Content = new TextContentBlock { Text = "" }, Model = "model", Role = Role.User }, McpJsonUtilities.DefaultOptions),
77+
Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Contents = [new TextContentBlock { Text = "" }], Model = "model", Role = Role.User }, McpJsonUtilities.DefaultOptions),
7878
}, cancellationToken);
7979
}
8080

tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ public async Task Sampling_Sse_TestServer()
260260
{
261261
Model = "test-model",
262262
Role = Role.Assistant,
263-
Content = new TextContentBlock { Text = "Test response" },
263+
Contents = [new TextContentBlock { Text = "Test response" }],
264264
};
265265
};
266266
await using var client = await GetClientAsync(options);

tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ public async Task Sampling_DoesNotCloseStream_Prematurely()
179179
{
180180
Model = "test-model",
181181
Role = Role.Assistant,
182-
Content = new TextContentBlock { Text = "Sampling response from client" },
182+
Contents = [new TextContentBlock { Text = "Sampling response from client" }],
183183
};
184184
},
185185
},

tests/ModelContextProtocol.Tests/Client/McpClientCreationTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,10 @@ public async Task CreateAsync_WithCapabilitiesOptions(Type transportType)
6868
SamplingHandler = async (c, p, t) =>
6969
new CreateMessageResult
7070
{
71-
Content = new TextContentBlock { Text = "result" },
72-
Model = "test-model",
73-
Role = Role.User,
74-
StopReason = "endTurn"
71+
Contents = [new TextContentBlock { Text = "result" }],
72+
Model = "test-model",
73+
Role = Role.User,
74+
StopReason = "endTurn"
7575
},
7676
},
7777
Roots = new RootsCapability

tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ public async Task Sampling_Stdio(string clientId)
385385
{
386386
Model = "test-model",
387387
Role = Role.Assistant,
388-
Content = new TextContentBlock { Text = "Test response" },
388+
Contents = [new TextContentBlock { Text = "Test response" }],
389389
};
390390
},
391391
},

tests/ModelContextProtocol.Tests/DockerEverythingServerTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ public async Task Sampling_Sse_EverythingServer()
8383
{
8484
Model = "test-model",
8585
Role = Role.Assistant,
86-
Content = new TextContentBlock { Text = "Test response" },
86+
Contents = [new TextContentBlock { Text = "Test response" }],
8787
};
8888
},
8989
},

0 commit comments

Comments
 (0)