Skip to content

Commit 7d8e081

Browse files
Support multiple contents in sampling results.
1 parent d54a239 commit 7d8e081

11 files changed

Lines changed: 158 additions & 14 deletions

File tree

src/ModelContextProtocol.Core/Client/McpClientExtensions.cs

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

958958
return new()
959959
{
960-
Content = content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty },
960+
Contents = [content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty }],
961961
Model = chatResponse.ModelId ?? "unknown",
962962
Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant,
963963
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",

src/ModelContextProtocol.Core/McpEndpointExtensions.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,11 @@ internal static async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
8383
{
8484
Id = requestId,
8585
Method = method,
86-
Params = JsonSerializer.SerializeToNode(parameters, parametersTypeInfo),
86+
Params = McpJsonUtilities.SerializeContextual(parameters, parametersTypeInfo, endpoint),
8787
};
8888

8989
JsonRpcResponse response = await endpoint.SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
90-
return JsonSerializer.Deserialize(response.Result, resultTypeInfo) ?? throw new JsonException("Unexpected JSON result in response.");
90+
return McpJsonUtilities.DeserializeContextual(response.Result, resultTypeInfo, endpoint) ?? throw new JsonException("Unexpected JSON result in response.");
9191
}
9292

9393
/// <summary>
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 IMcpEndpoint? t_currentMcpEndpoint;
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, IMcpEndpoint endpoint)
18+
{
19+
if (endpoint is null)
20+
{
21+
Throw.IfNull(endpoint);
22+
}
23+
24+
if (t_currentMcpEndpoint is not null)
25+
{
26+
throw new InvalidOperationException("Reentrant call to McpJsonUtilities.SerializeContextual detected.");
27+
}
28+
29+
t_currentMcpEndpoint = endpoint;
30+
try
31+
{
32+
return JsonSerializer.SerializeToNode(value!, typeInfo);
33+
}
34+
finally
35+
{
36+
t_currentMcpEndpoint = 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, IMcpEndpoint endpoint)
44+
{
45+
if (endpoint is null)
46+
{
47+
Throw.IfNull(endpoint);
48+
}
49+
50+
if (t_currentMcpEndpoint is not null)
51+
{
52+
throw new InvalidOperationException("Reentrant call to McpJsonUtilities.DeserializeContextual detected.");
53+
}
54+
55+
t_currentMcpEndpoint = endpoint;
56+
try
57+
{
58+
return JsonSerializer.Deserialize(node, typeInfo);
59+
}
60+
finally
61+
{
62+
t_currentMcpEndpoint = 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, IMcpEndpoint? endpoint, JsonSerializerOptions options);
77+
78+
/// <summary>
79+
/// Writes the JSON representation of the value.
80+
/// </summary>
81+
public abstract void Write(Utf8JsonWriter writer, T value, IMcpEndpoint? endpoint, JsonSerializerOptions options);
82+
83+
/// <inheritdoc/>
84+
public sealed override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
85+
Read(ref reader, t_currentMcpEndpoint, options);
86+
87+
/// <inheritdoc/>
88+
public sealed override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) =>
89+
Write(writer, value, t_currentMcpEndpoint, 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, IMcpEndpoint? endpoint, 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, IMcpEndpoint? endpoint, JsonSerializerOptions options)
95+
{
96+
if (endpoint?.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
@@ -75,7 +75,7 @@ private async Task SamplingAsync(JsonRpcRequest request, CancellationToken cance
7575
await WriteMessageAsync(new JsonRpcResponse
7676
{
7777
Id = request.Id,
78-
Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Content = new TextContentBlock { Text = "" }, Model = "model", Role = Role.User }, McpJsonUtilities.DefaultOptions),
78+
Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Contents = [new TextContentBlock { Text = "" }], Model = "model", Role = Role.User }, McpJsonUtilities.DefaultOptions),
7979
}, cancellationToken);
8080
}
8181

tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ public async Task Sampling_Sse_TestServer()
259259
{
260260
Model = "test-model",
261261
Role = Role.Assistant,
262-
Content = new TextContentBlock { Text = "Test response" },
262+
Contents = [new TextContentBlock { Text = "Test response" }],
263263
};
264264
};
265265
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/McpClientFactoryTests.cs

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

tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs

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

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)