diff --git a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs b/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs index 2e5bc0c41..6d9bac23c 100644 --- a/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/CreateTaskResult.cs @@ -55,10 +55,11 @@ public sealed class CreateTaskResult : Result public required DateTimeOffset LastUpdatedAt { get; set; } /// - /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// Gets or sets the time-to-live duration from creation, or for unlimited. /// [JsonPropertyName("ttlMs")] - public long? TtlMs { get; set; } + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } /// /// Gets or sets the suggested polling interval in milliseconds. diff --git a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs b/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs index 9366f2374..3fea8cc6b 100644 --- a/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/GetTaskResult.cs @@ -64,10 +64,10 @@ private protected GetTaskResult() public required DateTimeOffset LastUpdatedAt { get; set; } /// - /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// Gets or sets the time-to-live duration from creation, or for unlimited. /// [JsonPropertyName("ttlMs")] - public long? TtlMs { get; set; } + public TimeSpan? TimeToLive { get; set; } /// /// Gets or sets the suggested polling interval in milliseconds. @@ -245,7 +245,7 @@ internal sealed class Converter : JsonConverter }; taskResult.StatusMessage = statusMessage; - taskResult.TtlMs = ttlMs; + taskResult.TimeToLive = ttlMs is null ? null : TimeSpan.FromMilliseconds(ttlMs.Value); taskResult.PollIntervalMs = pollIntervalMs; taskResult.ResultType = resultType; taskResult.Meta = meta; @@ -287,9 +287,9 @@ public override void Write(Utf8JsonWriter writer, GetTaskResult value, JsonSeria writer.WriteString("createdAt", value.CreatedAt); writer.WriteString("lastUpdatedAt", value.LastUpdatedAt); - if (value.TtlMs is not null) + if (value.TimeToLive is not null) { - writer.WriteNumber("ttlMs", value.TtlMs.Value); + writer.WriteNumber("ttlMs", (long)value.TimeToLive.Value.TotalMilliseconds); } if (value.PollIntervalMs is not null) diff --git a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs b/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs index e1bbb2f73..4e859a22c 100644 --- a/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/TaskStatusNotificationParams.cs @@ -70,10 +70,10 @@ private protected TaskStatusNotificationParams() public required DateTimeOffset LastUpdatedAt { get; set; } /// - /// Gets or sets the time-to-live duration from creation in milliseconds, or for unlimited. + /// Gets or sets the time-to-live duration from creation, or for unlimited. /// [JsonPropertyName("ttlMs")] - public long? TtlMs { get; set; } + public TimeSpan? TimeToLive { get; set; } /// /// Gets or sets the suggested polling interval in milliseconds. @@ -247,7 +247,7 @@ internal sealed class Converter : JsonConverter }; notification.StatusMessage = statusMessage; - notification.TtlMs = ttlMs; + notification.TimeToLive = ttlMs is null ? null : TimeSpan.FromMilliseconds(ttlMs.Value); notification.PollIntervalMs = pollIntervalMs; notification.Meta = meta; @@ -283,9 +283,9 @@ public override void Write(Utf8JsonWriter writer, TaskStatusNotificationParams v writer.WriteString("createdAt", value.CreatedAt); writer.WriteString("lastUpdatedAt", value.LastUpdatedAt); - if (value.TtlMs is not null) + if (value.TimeToLive is not null) { - writer.WriteNumber("ttlMs", value.TtlMs.Value); + writer.WriteNumber("ttlMs", (long)value.TimeToLive.Value.TotalMilliseconds); } if (value.PollIntervalMs is not null) diff --git a/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs new file mode 100644 index 000000000..9ad646b48 --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol; + +/// +/// Provides a JSON converter for values that serializes and deserializes +/// them as a whole-millisecond integer. +/// +public sealed class TimeSpanMillisecondsConverter : JsonConverter +{ + /// + public override TimeSpan Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => TimeSpan.FromMilliseconds(reader.GetDouble()); + + /// + public override void Write(Utf8JsonWriter writer, TimeSpan value, JsonSerializerOptions options) + => writer.WriteNumberValue((long)value.TotalMilliseconds); +} diff --git a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs index a13af13e9..00c69844b 100644 --- a/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs +++ b/src/ModelContextProtocol.Core/Server/InMemoryMcpTaskStore.cs @@ -32,9 +32,9 @@ public class InMemoryMcpTaskStore : IMcpTaskStore public long DefaultPollIntervalMs { get; set; } = 1000; /// - /// Gets or sets the default time-to-live in milliseconds for new tasks, or for unlimited. + /// Gets or sets the default time-to-live for new tasks, or for unlimited. /// - public long? DefaultTtlMs { get; set; } + public TimeSpan? DefaultTimeToLive { get; set; } /// public Task CreateTaskAsync(CancellationToken cancellationToken = default) @@ -42,7 +42,7 @@ public Task CreateTaskAsync(CancellationToken cancellationToken = d var taskId = Guid.NewGuid().ToString("N"); var now = DateTimeOffset.UtcNow; - var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTtlMs, DefaultPollIntervalMs); + var info = new McpTaskInfo(taskId, McpTaskStatus.Working, now, now, DefaultTimeToLive, DefaultPollIntervalMs); _tasks[taskId] = info; return Task.FromResult(info); diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 85b1cc26a..1cce71c70 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1009,7 +1009,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) Status = info.Status, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "task", @@ -1022,7 +1022,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "complete", @@ -1032,7 +1032,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, Result = info.Result ?? throw new InvalidOperationException($"Task '{info.TaskId}' is completed but has no result."), @@ -1043,7 +1043,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, Error = info.Error ?? throw new InvalidOperationException($"Task '{info.TaskId}' is failed but has no error."), @@ -1054,7 +1054,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, ResultType = "complete", @@ -1064,7 +1064,7 @@ await originalListToolsHandler(request, cancellationToken).ConfigureAwait(false) TaskId = info.TaskId, CreatedAt = info.CreatedAt, LastUpdatedAt = info.LastUpdatedAt, - TtlMs = info.TtlMs, + TimeToLive = info.TimeToLive, PollIntervalMs = info.PollIntervalMs, StatusMessage = info.StatusMessage, // McpTaskInfo.InputRequests is IReadOnlyDictionary (covers immutable store diff --git a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs b/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs index ab71d6505..87fa8ef1c 100644 --- a/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs +++ b/src/ModelContextProtocol.Core/Server/McpTaskInfo.cs @@ -20,7 +20,7 @@ public sealed record McpTaskInfo( McpTaskStatus Status, DateTimeOffset CreatedAt, DateTimeOffset LastUpdatedAt, - long? TtlMs = null, + TimeSpan? TimeToLive = null, long? PollIntervalMs = null, string? StatusMessage = null, JsonElement? Result = null, diff --git a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs index 556403add..86acc57f6 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/TaskSerializationTests.cs @@ -21,7 +21,7 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie StatusMessage = "Processing...", CreatedAt = new DateTimeOffset(2025, 6, 1, 12, 0, 0, TimeSpan.Zero), LastUpdatedAt = new DateTimeOffset(2025, 6, 1, 12, 5, 0, TimeSpan.Zero), - TtlMs = 3600000, + TimeToLive = TimeSpan.FromHours(1), PollIntervalMs = 5000, ResultType = "task", Meta = new JsonObject { ["key"] = "value" } @@ -36,7 +36,7 @@ public static void CreateTaskResult_SerializationRoundTrip_PreservesAllPropertie Assert.Equal("Processing...", deserialized.StatusMessage); Assert.Equal(original.CreatedAt, deserialized.CreatedAt); Assert.Equal(original.LastUpdatedAt, deserialized.LastUpdatedAt); - Assert.Equal(3600000, deserialized.TtlMs); + Assert.Equal(TimeSpan.FromHours(1), deserialized.TimeToLive); Assert.Equal(5000, deserialized.PollIntervalMs); Assert.Equal("task", deserialized.ResultType); Assert.NotNull(deserialized.Meta); @@ -52,7 +52,7 @@ public static void CreateTaskResult_UsesCorrectWireFieldNames() Status = McpTaskStatus.Working, CreatedAt = DateTimeOffset.UtcNow, LastUpdatedAt = DateTimeOffset.UtcNow, - TtlMs = 60000, + TimeToLive = TimeSpan.FromMinutes(1), PollIntervalMs = 1000, ResultType = "task", }; diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs index 7c87786eb..83afbfe23 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpTaskStoreTests.cs @@ -55,13 +55,13 @@ public async Task CreateTaskAsync_UsesDefaultPollInterval() } [Fact] - public async Task CreateTaskAsync_UsesDefaultTtl() + public async Task CreateTaskAsync_UsesDefaultTimeToLive() { - var store = new InMemoryMcpTaskStore { DefaultTtlMs = 30000 }; + var store = new InMemoryMcpTaskStore { DefaultTimeToLive = TimeSpan.FromSeconds(30) }; var result = await store.CreateTaskAsync(CT); - Assert.Equal(30000, result.TtlMs); + Assert.Equal(TimeSpan.FromSeconds(30), result.TimeToLive); } [Fact] diff --git a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs index 1b424d002..0e8693353 100644 --- a/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/TaskCancellationIntegrationTests.cs @@ -34,7 +34,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer options.TaskStore = new InMemoryMcpTaskStore { DefaultPollIntervalMs = 50, - DefaultTtlMs = 5000, + DefaultTimeToLive = TimeSpan.FromSeconds(5), }; });