Skip to content
Open
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
4 changes: 2 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<System9Version>9.0.5</System9Version>
<System10Version>10.0.0-preview.4.25258.110</System10Version>
<MicrosoftExtensionsAIVersion>9.8.0</MicrosoftExtensionsAIVersion>
<MicrosoftExtensionsAIVersion>9.9.0</MicrosoftExtensionsAIVersion>
</PropertyGroup>

<!-- Product dependencies netstandard -->
Expand Down Expand Up @@ -53,7 +53,7 @@
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.8.0-preview.1.25412.6" />
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.9.0-preview.1.25458.4" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="$(System9Version)" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="$(System9Version)" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="$(System9Version)" />
Expand Down
2 changes: 1 addition & 1 deletion src/ModelContextProtocol.Core/Client/McpClient.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -623,7 +623,7 @@ internal static CreateMessageResult ToCreateMessageResult(ChatResponse chatRespo

return new()
{
Content = content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty },
Contents = [content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty }],
Model = chatResponse.ModelId ?? "unknown",
Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant,
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -703,7 +703,7 @@ internal static CreateMessageResult ToCreateMessageResult(this ChatResponse chat

return new()
{
Content = content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty },
Contents = [content ?? new TextContentBlock { Text = lastMessage?.Text ?? string.Empty }],
Model = chatResponse.ModelId ?? "unknown",
Role = lastMessage?.Role == ChatRole.User ? Role.User : Role.Assistant,
StopReason = chatResponse.FinishReason == ChatFinishReason.Length ? "maxTokens" : "endTurn",
Expand Down
91 changes: 91 additions & 0 deletions src/ModelContextProtocol.Core/McpJsonUtilities.McpJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace ModelContextProtocol;

public static partial class McpJsonUtilities
{
[ThreadStatic]
private static McpSession? t_currentMcpSession;

/// <summary>
/// Serializes the given value to a <see cref="JsonNode"/> using the provided <see cref="JsonTypeInfo{T}"/>,
/// </summary>
internal static JsonNode? SerializeContextual<T>(T? value, JsonTypeInfo<T> typeInfo, McpSession session)
{
if (session is null)
{
Throw.IfNull(session);
}

if (t_currentMcpSession is not null)
{
throw new InvalidOperationException("Reentrant call to McpJsonUtilities.SerializeContextual detected.");
}

t_currentMcpSession = session;
try
{
return JsonSerializer.SerializeToNode(value!, typeInfo);
}
finally
{
t_currentMcpSession = null;
}
}

/// <summary>
/// Deserializes the given value to a <see cref="JsonNode"/> using the provided <see cref="JsonTypeInfo{T}"/>,
/// </summary>
internal static T? DeserializeContextual<T>(JsonNode? node, JsonTypeInfo<T> typeInfo, McpSession session)
{
if (session is null)
{
Throw.IfNull(session);
}

if (t_currentMcpSession is not null)
{
throw new InvalidOperationException("Reentrant call to McpJsonUtilities.DeserializeContextual detected.");
}

t_currentMcpSession = session;
try
{
return JsonSerializer.Deserialize(node, typeInfo);
}
finally
{
t_currentMcpSession = null;
}
}

/// <summary>
/// Defines an abstract JSON converter that has access to the current <see cref="IMcpEndpoint"/> context during serialization and deserialization.
/// </summary>
/// <typeparam name="T">The type being converted.</typeparam>
[EditorBrowsable(EditorBrowsableState.Never)]
public abstract class McpContextualJsonConverter<T> : JsonConverter<T>
{
/// <summary>
/// Reads the JSON representation of the value.
/// </summary>
public abstract T? Read(ref Utf8JsonReader reader, McpSession? session, JsonSerializerOptions options);

/// <summary>
/// Writes the JSON representation of the value.
/// </summary>
public abstract void Write(Utf8JsonWriter writer, T value, McpSession? session, JsonSerializerOptions options);

/// <inheritdoc/>
public sealed override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
Read(ref reader, t_currentMcpSession, options);

/// <inheritdoc/>
public sealed override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) =>
Write(writer, value, t_currentMcpSession, options);
}
}
55 changes: 54 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol;
Expand All @@ -14,7 +16,26 @@ public sealed class CreateMessageResult : Result
/// Gets or sets the content of the message.
/// </summary>
[JsonPropertyName("content")]
public required ContentBlock Content { get; init; }
[JsonConverter(typeof(SingleOrArrayContentConverter))]
public required List<ContentBlock> Contents
{
get;
init
{
if (value is null or [])
{
throw new ArgumentException(nameof(Contents));
}

field = value;
}
}

/// <summary>
/// Gets or sets the content of the message.
/// </summary>
[JsonIgnore]
public ContentBlock Content { get => Contents.First(); init => Contents = [value]; }

/// <summary>
/// Gets or sets the name of the model that generated the message.
Expand Down Expand Up @@ -50,4 +71,36 @@ public sealed class CreateMessageResult : Result
/// </summary>
[JsonPropertyName("role")]
public required Role Role { get; init; }

/// <summary>
/// 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}"/>.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class SingleOrArrayContentConverter : McpJsonUtilities.McpContextualJsonConverter<List<ContentBlock>>
{
/// <inheritdoc/>
public override List<ContentBlock>? Read(ref Utf8JsonReader reader, McpSession? session, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.StartObject)
{
var single = JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<ContentBlock>());
return [single];
}

return JsonSerializer.Deserialize(ref reader, options.GetTypeInfo<List<ContentBlock>>());
}

/// <inheritdoc/>
public override void Write(Utf8JsonWriter writer, List<ContentBlock> value, McpSession? session, JsonSerializerOptions options)
{
if (session?.NegotiatedProtocolVersion is string version &&
DateTime.Parse(version) < new DateTime(2025, 09, 18)) // A hypothetical future version
{
// The negotiated protocol version is before 2025-09-18, so we need to serialize as a single object.
JsonSerializer.Serialize(value.Single(), options.GetTypeInfo<ContentBlock>());
}

JsonSerializer.Serialize(value, options.GetTypeInfo<List<ContentBlock>>());
}
}
}
2 changes: 1 addition & 1 deletion tests/Common/Utils/TestServerTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ private async Task SamplingAsync(JsonRpcRequest request, CancellationToken cance
await WriteMessageAsync(new JsonRpcResponse
{
Id = request.Id,
Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Content = new TextContentBlock { Text = "" }, Model = "model", Role = Role.User }, McpJsonUtilities.DefaultOptions),
Result = JsonSerializer.SerializeToNode(new CreateMessageResult { Contents = [new TextContentBlock { Text = "" }], Model = "model", Role = Role.User }, McpJsonUtilities.DefaultOptions),
}, cancellationToken);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ public async Task Sampling_Sse_TestServer()
{
Model = "test-model",
Role = Role.Assistant,
Content = new TextContentBlock { Text = "Test response" },
Contents = [new TextContentBlock { Text = "Test response" }],
};
};
await using var client = await GetClientAsync(options);
Expand Down
2 changes: 1 addition & 1 deletion tests/ModelContextProtocol.AspNetCore.Tests/MapMcpTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ public async Task Sampling_DoesNotCloseStream_Prematurely()
{
Model = "test-model",
Role = Role.Assistant,
Content = new TextContentBlock { Text = "Sampling response from client" },
Contents = [new TextContentBlock { Text = "Sampling response from client" }],
};
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,10 @@ public async Task CreateAsync_WithCapabilitiesOptions(Type transportType)
SamplingHandler = async (c, p, t) =>
new CreateMessageResult
{
Content = new TextContentBlock { Text = "result" },
Model = "test-model",
Role = Role.User,
StopReason = "endTurn"
Contents = [new TextContentBlock { Text = "result" }],
Model = "test-model",
Role = Role.User,
StopReason = "endTurn"
},
},
Roots = new RootsCapability
Expand Down
2 changes: 1 addition & 1 deletion tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ public async Task Sampling_Stdio(string clientId)
{
Model = "test-model",
Role = Role.Assistant,
Content = new TextContentBlock { Text = "Test response" },
Contents = [new TextContentBlock { Text = "Test response" }],
};
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ public async Task Sampling_Sse_EverythingServer()
{
Model = "test-model",
Role = Role.Assistant,
Content = new TextContentBlock { Text = "Test response" },
Contents = [new TextContentBlock { Text = "Test response" }],
};
},
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public async Task SampleAsync_Request_Forwards_To_McpServer_SendRequestAsync()

var resultPayload = new CreateMessageResult
{
Content = new TextContentBlock { Text = "resp" },
Contents = [new TextContentBlock { Text = "resp" }],
Model = "test-model",
Role = Role.Assistant,
StopReason = "endTurn",
Expand Down Expand Up @@ -113,7 +113,7 @@ public async Task SampleAsync_Messages_Forwards_To_McpServer_SendRequestAsync()

var resultPayload = new CreateMessageResult
{
Content = new TextContentBlock { Text = "resp" },
Contents = [new TextContentBlock { Text = "resp" }],
Model = "test-model",
Role = Role.Assistant,
StopReason = "endTurn",
Expand Down
2 changes: 1 addition & 1 deletion tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,7 @@ public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, C

CreateMessageResult result = new()
{
Content = new TextContentBlock { Text = "The Eiffel Tower." },
Contents = [new TextContentBlock { Text = "The Eiffel Tower." }],
Model = "amazingmodel",
Role = Role.Assistant,
StopReason = "endTurn",
Expand Down
Loading