Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
53 changes: 53 additions & 0 deletions src/ModelContextProtocol.Core/ExperimentalJsonConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;

namespace ModelContextProtocol;

/// <summary>
/// A JSON converter that handles serialization of experimental MCP types through <c>object?</c> backing fields.
/// </summary>
/// <typeparam name="T">The experimental type to serialize/deserialize.</typeparam>
/// <remarks>
/// <para>
/// This converter is used on internal <c>object?</c> backing fields that shadow public experimental properties
/// marked with <see cref="ExperimentalAttribute"/>. By declaring the backing field
/// as <c>object?</c>, the System.Text.Json source generator does not walk the experimental type graph, preventing
/// MCPEXP diagnostics from being emitted in generated code in consuming projects.
/// </para>
/// <para>
/// Serialization delegates to <see cref="McpJsonUtilities.DefaultOptions"/>, which already contains source-generated
/// contracts for all experimental types.
/// </para>
/// </remarks>
internal sealed class ExperimentalJsonConverter<T> : JsonConverter<object?> where T : class
{
private static JsonTypeInfo<T> TypeInfo => (JsonTypeInfo<T>)McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(T));

public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.Null)
{
return null;
}

return JsonSerializer.Deserialize(ref reader, TypeInfo);
}

public override void Write(Utf8JsonWriter writer, object? value, JsonSerializerOptions options)
{
if (value is null)
{
writer.WriteNullValue();
return;
}

if (value is not T typed)
{
throw new JsonException($"Expected value of type '{typeof(T).Name}' but got '{value.GetType().Name}'.");
}

JsonSerializer.Serialize(writer, typed, TypeInfo);
}
}
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/CallToolRequestParams.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

Expand Down Expand Up @@ -33,6 +34,16 @@ public sealed class CallToolRequestParams : RequestParams
/// When present, indicates that the requestor wants this operation executed as a task.
/// The receiver must support task augmentation for this specific request type.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
[JsonIgnore]
public McpTaskMetadata? Task
{
get => _task as McpTaskMetadata;
set => _task = value;
}

[JsonInclude]
[JsonPropertyName("task")]
public McpTaskMetadata? Task { get; set; }
[JsonConverter(typeof(ExperimentalJsonConverter<McpTaskMetadata>))]
internal object? _task;
Comment thread
MackinnonBuck marked this conversation as resolved.
Outdated
}
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/CallToolResult.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;

Expand Down Expand Up @@ -65,6 +66,16 @@ public sealed class CallToolResult : Result
/// (<see cref="Content"/>, <see cref="StructuredContent"/>, <see cref="IsError"/>) may not be populated.
/// The actual tool result can be retrieved later via <c>tasks/result</c>.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
[JsonIgnore]
public McpTask? Task
{
get => _task as McpTask;
set => _task = value;
}

[JsonInclude]
[JsonPropertyName("task")]
public McpTask? Task { get; set; }
[JsonConverter(typeof(ExperimentalJsonConverter<McpTask>))]
internal object? _task;
}
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ModelContextProtocol.Client;
using ModelContextProtocol.Server;
Expand Down Expand Up @@ -80,6 +81,16 @@ public sealed class ClientCapabilities
/// See <see cref="McpTasksCapability"/> for details on configuring which operations support tasks.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
[JsonIgnore]
public McpTasksCapability? Tasks
{
get => _tasks as McpTasksCapability;
set => _tasks = value;
}

[JsonInclude]
[JsonPropertyName("tasks")]
public McpTasksCapability? Tasks { get; set; }
[JsonConverter(typeof(ExperimentalJsonConverter<McpTasksCapability>))]
internal object? _tasks;
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using System.Text.Json.Serialization;

Expand Down Expand Up @@ -128,6 +129,16 @@ public sealed class CreateMessageRequestParams : RequestParams
/// When present, indicates that the requestor wants this operation executed as a task.
/// The receiver must support task augmentation for this specific request type.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
[JsonIgnore]
public McpTaskMetadata? Task
{
get => _task as McpTaskMetadata;
set => _task = value;
}

[JsonInclude]
[JsonPropertyName("task")]
public McpTaskMetadata? Task { get; set; }
[JsonConverter(typeof(ExperimentalJsonConverter<McpTaskMetadata>))]
internal object? _task;
}
12 changes: 11 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ElicitRequestParams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,18 @@ public string Mode
/// When present, indicates that the requestor wants this operation executed as a task.
/// The receiver must support task augmentation for this specific request type.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
[JsonIgnore]
public McpTaskMetadata? Task
{
get => _task as McpTaskMetadata;
set => _task = value;
}

[JsonInclude]
[JsonPropertyName("task")]
public McpTaskMetadata? Task { get; set; }
[JsonConverter(typeof(ExperimentalJsonConverter<McpTaskMetadata>))]
internal object? _task;

/// <summary>Represents a request schema used in a form mode elicitation request.</summary>
public sealed class RequestSchema
Expand Down
13 changes: 12 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/ServerCapabilities.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Serialization;
using ModelContextProtocol.Server;

Expand Down Expand Up @@ -79,6 +80,16 @@ public sealed class ServerCapabilities
/// See <see cref="McpTasksCapability"/> for details on configuring which operations support tasks.
/// </para>
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
[JsonIgnore]
public McpTasksCapability? Tasks
{
get => _tasks as McpTasksCapability;
set => _tasks = value;
}

[JsonInclude]
[JsonPropertyName("tasks")]
public McpTasksCapability? Tasks { get; set; }
[JsonConverter(typeof(ExperimentalJsonConverter<McpTasksCapability>))]
internal object? _tasks;
}
11 changes: 10 additions & 1 deletion src/ModelContextProtocol.Core/Protocol/Tool.cs
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,17 @@ public JsonElement? OutputSchema
/// regarding task augmentation support. See <see cref="ToolExecution"/> for details.
/// </remarks>
[Experimental(Experimentals.Tasks_DiagnosticId, UrlFormat = Experimentals.Tasks_Url)]
[JsonIgnore]
public ToolExecution? Execution
{
get => _execution as ToolExecution;
set => _execution = value;
}

[JsonInclude]
[JsonPropertyName("execution")]
public ToolExecution? Execution { get; set; }
[JsonConverter(typeof(ExperimentalJsonConverter<ToolExecution>))]
internal object? _execution;

/// <summary>
/// Gets or sets an optional list of icons for this tool.
Expand Down
186 changes: 186 additions & 0 deletions tests/ModelContextProtocol.Tests/ExperimentalJsonConverterTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;

namespace ModelContextProtocol.Tests;

public static class ExperimentalJsonConverterTests
Comment thread
MackinnonBuck marked this conversation as resolved.
{
[Fact]
public static void Tool_WithExecution_RoundTrips()
{
var original = new Tool
{
Name = "test-tool",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
};

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<Tool>(json, McpJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.Equal("test-tool", deserialized.Name);
Assert.NotNull(deserialized.Execution);
Assert.Equal(ToolTaskSupport.Optional, deserialized.Execution.TaskSupport);
}

[Fact]
public static void Tool_WithNullExecution_RoundTrips()
{
var original = new Tool { Name = "simple-tool" };

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<Tool>(json, McpJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.Null(deserialized.Execution);
Assert.DoesNotContain("execution", json);
}

[Fact]
public static void ServerCapabilities_WithTasks_RoundTrips()
{
var original = new ServerCapabilities
{
Tasks = new McpTasksCapability()
};

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ServerCapabilities>(json, McpJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Tasks);
}

[Fact]
public static void ServerCapabilities_WithNullTasks_OmitsProperty()
{
var original = new ServerCapabilities();

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);

Assert.DoesNotContain("tasks", json);
}

[Fact]
public static void ClientCapabilities_WithTasks_RoundTrips()
{
var original = new ClientCapabilities
{
Tasks = new McpTasksCapability()
};

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ClientCapabilities>(json, McpJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Tasks);
}

[Fact]
public static void CallToolResult_WithTask_RoundTrips()
{
var original = new CallToolResult
{
Task = new McpTask
{
TaskId = "task-123",
Status = McpTaskStatus.Working,
CreatedAt = DateTimeOffset.UtcNow,
LastUpdatedAt = DateTimeOffset.UtcNow,
}
};

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<CallToolResult>(json, McpJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Task);
Assert.Equal("task-123", deserialized.Task.TaskId);
Assert.Equal(McpTaskStatus.Working, deserialized.Task.Status);
}

[Fact]
public static void CallToolRequestParams_WithTask_RoundTrips()
{
var original = new CallToolRequestParams
{
Name = "my-tool",
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(5) }
};

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<CallToolRequestParams>(json, McpJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.Equal("my-tool", deserialized.Name);
Assert.NotNull(deserialized.Task);
Assert.Equal(TimeSpan.FromMinutes(5), deserialized.Task.TimeToLive);
}

[Fact]
public static void CreateMessageRequestParams_WithTask_RoundTrips()
{
var original = new CreateMessageRequestParams
{
Messages = [],
MaxTokens = 100,
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(10) }
};

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<CreateMessageRequestParams>(json, McpJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Task);
Assert.Equal(TimeSpan.FromMinutes(10), deserialized.Task.TimeToLive);
}

[Fact]
public static void ElicitRequestParams_WithTask_RoundTrips()
{
var original = new ElicitRequestParams
{
Message = "test prompt",
Task = new McpTaskMetadata { TimeToLive = TimeSpan.FromMinutes(15) }
};

string json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<ElicitRequestParams>(json, McpJsonUtilities.DefaultOptions);

Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Task);
Assert.Equal(TimeSpan.FromMinutes(15), deserialized.Task.TimeToLive);
}

[Fact]
public static void Tool_WithExecution_JsonPropertyNameIsCorrect()
{
var tool = new Tool
{
Name = "test",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required }
};

string json = JsonSerializer.Serialize(tool, McpJsonUtilities.DefaultOptions);

Assert.Contains("\"execution\"", json);
Assert.Contains("\"taskSupport\"", json);
}

[Fact]
public static void Tool_WriteIndented_IsRespected()
{
var tool = new Tool
{
Name = "test",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Optional }
};

// Use caller options with WriteIndented = true
var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions) { WriteIndented = true };
string json = JsonSerializer.Serialize(tool, options);

// The output should be indented because WriteIndented is controlled by the writer
Assert.Contains("\n", json);
}
}