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
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
5 changes: 3 additions & 2 deletions src/ModelContextProtocol.Core/Client/McpClientImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,13 @@ private void RegisterHandlers(ClientCapabilities capabilities, NotificationHandl

requestHandlers.Set(
RequestMethods.SamplingCreateMessage,
this,
(request, _, cancellationToken) => samplingHandler(
request,
request?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
cancellationToken),
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageResult);
CreateMessageRequestParams.ModelSerializer,
CreateMessageResult.ModelSerializer);
}

if (capabilities.Roots is { } rootsCapability)
Expand Down
3 changes: 2 additions & 1 deletion src/ModelContextProtocol.Core/McpJsonUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ internal static bool IsValidMcpToolSchema(JsonElement element)
[JsonSerializable(typeof(CompleteRequestParams))]
[JsonSerializable(typeof(CompleteResult))]
[JsonSerializable(typeof(CreateMessageRequestParams))]
[JsonSerializable(typeof(CreateMessageResult))]
[JsonSerializable(typeof(CreateMessageResultDto_V1))]
[JsonSerializable(typeof(CreateMessageResultDto_V2))]
[JsonSerializable(typeof(ElicitRequestParams))]
[JsonSerializable(typeof(ElicitResult))]
[JsonSerializable(typeof(EmptyResult))]
Expand Down
36 changes: 36 additions & 0 deletions src/ModelContextProtocol.Core/McpSession.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,42 @@ internal async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
return JsonSerializer.Deserialize(response.Result, resultTypeInfo) ?? throw new JsonException("Unexpected JSON result in response.");
}

/// <summary>
/// Sends a JSON-RPC request and attempts to deserialize the result to <typeparamref name="TResult"/>.
/// </summary>
/// <typeparam name="TParameters">The type of the request parameters to serialize from.</typeparam>
/// <typeparam name="TResult">The type of the result to deserialize to.</typeparam>
/// <param name="method">The JSON-RPC method name to invoke.</param>
/// <param name="parameters">Object representing the request parameters.</param>
/// <param name="parametersSerializer">The request parameter serialization delegate.</param>
/// <param name="resultSerializer">The result deserialization delegate.</param>
/// <param name="requestId">The request id for the request.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> to monitor for cancellation requests. The default is <see cref="CancellationToken.None"/>.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the deserialized result.</returns>
internal async ValueTask<TResult> SendRequestAsync<TParameters, TResult>(
string method,
TParameters parameters,
IMcpModelSerializer<TParameters> parametersSerializer,
IMcpModelSerializer<TResult> resultSerializer,
RequestId requestId = default,
CancellationToken cancellationToken = default)
where TResult : notnull
{
Throw.IfNullOrWhiteSpace(method);
Throw.IfNull(parametersSerializer);
Throw.IfNull(resultSerializer);

JsonRpcRequest jsonRpcRequest = new()
{
Id = requestId,
Method = method,
Params = parametersSerializer.Serialize(parameters, this),
};

JsonRpcResponse response = await SendRequestAsync(jsonRpcRequest, cancellationToken).ConfigureAwait(false);
return resultSerializer.Deserialize(response.Result, this) ?? throw new JsonException("Unexpected JSON result in response.");
}

/// <summary>
/// Sends a parameterless notification to the connected session.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,4 +100,10 @@ public sealed class CreateMessageRequestParams : RequestParams
/// </summary>
[JsonPropertyName("temperature")]
public float? Temperature { get; init; }

/// <summary>
/// A <see cref="CreateMessageResult"/> serializer that wraps the source generated JsonTypeInfo for the type.
/// </summary>
internal static IMcpModelSerializer<CreateMessageRequestParams> ModelSerializer { get; } =
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams.ToMcpModelSerializer();
}
45 changes: 37 additions & 8 deletions src/ModelContextProtocol.Core/Protocol/CreateMessageResult.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol;

/// <summary>
Expand All @@ -13,8 +11,24 @@ public sealed class CreateMessageResult : Result
/// <summary>
/// Gets or sets the content of the message.
/// </summary>
[JsonPropertyName("content")]
public required ContentBlock Content { get; init; }
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>
public ContentBlock Content { get => Contents.First(); init => Contents = [value]; }

/// <summary>
/// Gets or sets the name of the model that generated the message.
Expand All @@ -28,7 +42,6 @@ public sealed class CreateMessageResult : Result
/// enabling appropriate handling based on the model's capabilities and characteristics.
/// </para>
/// </remarks>
[JsonPropertyName("model")]
public required string Model { get; init; }

/// <summary>
Expand All @@ -42,12 +55,28 @@ public sealed class CreateMessageResult : Result
/// <item><term>stopSequence</term><description>A specific stop sequence was encountered during generation.</description></item>
/// </list>
/// </remarks>
[JsonPropertyName("stopReason")]
public string? StopReason { get; init; }

/// <summary>
/// Gets or sets the role of the user who generated the message.
/// </summary>
[JsonPropertyName("role")]
public required Role Role { get; init; }
}

/// <summary>
/// A <see cref="CreateMessageResult"/> serializer that delegates to the appropriate versioned DTO serializer
/// </summary>
internal static IMcpModelSerializer<CreateMessageResult> ModelSerializer { get; } =
McpModelSerializer.CreateDelegatingSerializer(endpoint =>
{
if (endpoint?.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 use the V1 serializer.
return CreateMessageResultDto_V1.ModelSerializer;
}
else
{
return CreateMessageResultDto_V2.ModelSerializer;
}
});
}
87 changes: 87 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/CreateMessageResultDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

namespace ModelContextProtocol.Protocol;

internal sealed class CreateMessageResultDto_V2 // V1, V2, V3, etc. as a placeholder naming convention.
// Could also be the protocol version introducing the breaking change
{
[JsonPropertyName("content")]
public required List<ContentBlock> Content { get; init; }

[JsonPropertyName("model")]
public required string Model { get; init; }

[JsonPropertyName("stopReason")]
public string? StopReason { get; init; }

[JsonPropertyName("role")]
public required Role Role { get; init; }

[JsonPropertyName("_meta")]
public JsonObject? Meta { get; init; }

/// <summary>
/// The serializer for <see cref="CreateMessageResult"/> using this DTO.
/// </summary>
public static IMcpModelSerializer<CreateMessageResult> ModelSerializer { get; } =
McpModelSerializer.CreateDtoSerializer<CreateMessageResult, CreateMessageResultDto_V2>(
toDto: static model => new ()
{
Content = model.Contents,
Model = model.Model,
StopReason = model.StopReason,
Role = model.Role,
Meta = model.Meta
},
fromDto: static dto => new()
{
Contents = dto.Content,
Model = dto.Model,
StopReason = dto.StopReason,
Role = dto.Role,
Meta = dto.Meta
},
McpJsonUtilities.JsonContext.Default.CreateMessageResultDto_V2);
}

internal sealed class CreateMessageResultDto_V1 // V1, V2, V3, etc. as a placeholder naming convention.
{
[JsonPropertyName("content")]
public required ContentBlock Content { get; init; }

[JsonPropertyName("model")]
public required string Model { get; init; }

[JsonPropertyName("stopReason")]
public string? StopReason { get; init; }

[JsonPropertyName("role")]
public required Role Role { get; init; }

[JsonPropertyName("_meta")]
public JsonObject? Meta { get; init; }

/// <summary>
/// The serializer for <see cref="CreateMessageResult"/> using this DTO.
/// </summary>
public static IMcpModelSerializer<CreateMessageResult> ModelSerializer { get; } =
McpModelSerializer.CreateDtoSerializer<CreateMessageResult, CreateMessageResultDto_V1>(
toDto: static model => new()
{
Content = model.Content,
Model = model.Model,
StopReason = model.StopReason,
Role = model.Role,
Meta = model.Meta
},
fromDto: static dto => new()
{
Contents = [dto.Content],
Model = dto.Model,
StopReason = dto.StopReason,
Role = dto.Role,
Meta = dto.Meta
},
McpJsonUtilities.JsonContext.Default.CreateMessageResultDto_V1);
}
92 changes: 92 additions & 0 deletions src/ModelContextProtocol.Core/Protocol/IMcpModelSerializer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization.Metadata;

namespace ModelContextProtocol.Protocol;

/// <summary>
/// An abstraction for serializing and deserializing MCP model objects to/from JSON,
/// </summary>
/// <typeparam name="TModel">The model type being serialized.</typeparam>
internal interface IMcpModelSerializer<TModel>
{
public JsonNode? Serialize(TModel? model, McpSession session);
public TModel? Deserialize(JsonNode? node, McpSession session);
}

/// <summary>
/// Defines a set of factory methods for creating <see cref="IMcpModelSerializer{TModel}"/> instances.
/// </summary>
internal static class McpModelSerializer
{
/// <summary>
/// Creates an MCP model serializer that delegates to different serializers based on the MCP session.
/// </summary>
public static IMcpModelSerializer<TModel> CreateDelegatingSerializer<TModel>(Func<McpSession, IMcpModelSerializer<TModel>> selector) =>
new DelegatingMcpModelSerializer<TModel>(selector);

/// <summary>
/// Creates an MCP model serializer mapped from a <see cref="JsonTypeInfo{T}"/>.
/// </summary>
/// <typeparam name="TModel"></typeparam>
/// <param name="typeInfo"></param>
/// <returns></returns>
public static IMcpModelSerializer<TModel> ToMcpModelSerializer<TModel>(this JsonTypeInfo<TModel> typeInfo) =>
new JsonTypeInfoMcpModelSerializer<TModel>(typeInfo);

/// <summary>
/// Creates an MCP model serializer that maps between a model type and a DTO type for serialization.
/// </summary>
/// <typeparam name="TModel">The model type to serialize.</typeparam>
/// <typeparam name="TDto">The DTO used to drive serialization.</typeparam>
/// <param name="toDto">The model-to-dto mapper.</param>
/// <param name="fromDto">The dto-to-model inverse mapper.</param>
/// <param name="dtoTypeInfo">The <see cref="JsonTypeInfo"/> governing serialization of the DTO type.</param>
public static IMcpModelSerializer<TModel> CreateDtoSerializer<TModel, TDto>(
Func<TModel, TDto> toDto,
Func<TDto, TModel> fromDto,
JsonTypeInfo<TDto> dtoTypeInfo) =>
new DtoMappingMcpModelSerializer<TModel, TDto>(toDto, fromDto, dtoTypeInfo);

private sealed class JsonTypeInfoMcpModelSerializer<TModel>(JsonTypeInfo<TModel> typeInfo) : IMcpModelSerializer<TModel>
{
public TModel? Deserialize(JsonNode? node, McpSession _) => JsonSerializer.Deserialize(node, typeInfo);
public JsonNode? Serialize(TModel? model, McpSession _) => model is null ? null : JsonSerializer.SerializeToNode(model, typeInfo);
}

private sealed class DelegatingMcpModelSerializer<TModel>(Func<McpSession, IMcpModelSerializer<TModel>> selector) : IMcpModelSerializer<TModel>
{
public TModel? Deserialize(JsonNode? node, McpSession session)
{
var serializer = selector(session);
return serializer.Deserialize(node, session);
}

public JsonNode? Serialize(TModel? model, McpSession session)
{
var serializer = selector(session);
return serializer.Serialize(model, session);
}
}

private sealed class DtoMappingMcpModelSerializer<TModel, TDto>(Func<TModel, TDto> toDto, Func<TDto, TModel> fromDto, JsonTypeInfo<TDto> dtoTypeInfo) : IMcpModelSerializer<TModel>
{
public JsonNode? Serialize(TModel? model, McpSession _)
{
if (model is null)
{
return null;
}

TDto dto = toDto(model);
return JsonSerializer.SerializeToNode(dto, dtoTypeInfo);
}

public TModel? Deserialize(JsonNode? node, McpSession _)
{
TDto? dto = JsonSerializer.Deserialize(node, dtoTypeInfo);
return dto is null ? default : fromDto(dto);
}
}
}
Loading
Loading