Default draft protocol support: sessionless + handshake-less (SEP-2575 + SEP-2567) (#1610)

This commit is contained in:
Stephen Halter 2026-06-23 21:24:22 -07:00 • committed by GitHub
commit 6c323e783b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 5704 additions and 712 deletions

View file

@ -84,6 +84,9 @@ public class CancellationTests : ClientServerTestBase
var clientOptions = new McpClientOptions
{
// Pin to a legacy protocol version so the client performs the initialize handshake
// (the spec rule under test is "the initialize request MUST NOT be cancelled by clients").
ProtocolVersion = "2025-11-25",
InitializationTimeout = TimeSpan.FromMilliseconds(500),
};

View file

@ -0,0 +1,80 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Protocol;
/// <summary>
/// Serialization tests for the request/result types introduced by the draft protocol revision (SEP-2575).
/// </summary>
public static class DiscoverProtocolTests
{
[Fact]
public static void DiscoverRequestParams_SerializationRoundTrip_WithMeta()
{
var original = new DiscoverRequestParams
{
Meta = new JsonObject
{
[MetaKeys.ProtocolVersion] = "2026-07-28",
[MetaKeys.ClientInfo] = new JsonObject
{
["name"] = "test-client",
["version"] = "1.0",
},
[MetaKeys.ClientCapabilities] = new JsonObject(),
},
};
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<DiscoverRequestParams>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
Assert.NotNull(deserialized.Meta);
Assert.Equal("2026-07-28", (string)deserialized.Meta[MetaKeys.ProtocolVersion]!);
}
[Fact]
public static void DiscoverResult_SerializationRoundTrip_PreservesAllProperties()
{
var original = new DiscoverResult
{
SupportedVersions = new List<string> { "2025-11-25", "2026-07-28" },
Capabilities = new ServerCapabilities
{
Tools = new ToolsCapability { ListChanged = true },
},
ServerInfo = new Implementation { Name = "test-server", Version = "2.0" },
Instructions = "Use this server for testing.",
};
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
Assert.Equal(["2025-11-25", "2026-07-28"], deserialized.SupportedVersions);
Assert.NotNull(deserialized.Capabilities.Tools);
Assert.True(deserialized.Capabilities.Tools.ListChanged);
Assert.Equal("test-server", deserialized.ServerInfo.Name);
Assert.Equal("Use this server for testing.", deserialized.Instructions);
}
[Fact]
public static void DiscoverResult_SerializationRoundTrip_WithMinimalProperties()
{
var original = new DiscoverResult
{
SupportedVersions = new List<string> { "2026-07-28" },
Capabilities = new ServerCapabilities(),
ServerInfo = new Implementation { Name = "minimal-server", Version = "1.0" },
};
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
Assert.Single(deserialized.SupportedVersions);
Assert.Equal("2026-07-28", deserialized.SupportedVersions[0]);
Assert.Null(deserialized.Instructions);
}
}

View file

@ -0,0 +1,125 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Protocol;
/// <summary>
/// Targeted tests for the SEP-2549 caching hints (<c>ttlMs</c> and <c>cacheScope</c>) on
/// <see cref="DiscoverResult"/>. Spec PR #2855 promotes both fields to required on the discover
/// response. <see cref="DiscoverResult"/> has <c>required</c> CLR properties for
/// <see cref="DiscoverResult.SupportedVersions"/>, <see cref="DiscoverResult.Capabilities"/>, and
/// <see cref="DiscoverResult.ServerInfo"/>, which prevents reuse of the parameterized
/// <see cref="CacheableResultTests"/> helper (it instantiates via reflection). This file covers the
/// same property-shape assertions for <see cref="DiscoverResult"/>.
/// </summary>
public static class DiscoverResultCacheableTests
{
private static DiscoverResult NewDiscoverResult() => new()
{
SupportedVersions = ["2025-11-25", McpSession.DraftProtocolVersion],
Capabilities = new ServerCapabilities(),
ServerInfo = new Implementation { Name = "test-server", Version = "1.0" },
};
[Fact]
public static void DiscoverResult_SerializesTtlMsAsIntegerMilliseconds()
{
var result = NewDiscoverResult();
result.TimeToLive = TimeSpan.FromMilliseconds(300_000);
result.CacheScope = CacheScope.Public;
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
var node = JsonNode.Parse(json)!.AsObject();
Assert.True(node.ContainsKey("ttlMs"));
Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind());
Assert.Equal(300_000, node["ttlMs"]!.GetValue<long>());
Assert.Equal("public", node["cacheScope"]!.GetValue<string>());
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive);
Assert.Equal(CacheScope.Public, deserialized.CacheScope);
}
[Fact]
public static void DiscoverResult_PrivateScope_RoundTrips()
{
var result = NewDiscoverResult();
result.TimeToLive = TimeSpan.Zero;
result.CacheScope = CacheScope.Private;
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
var node = JsonNode.Parse(json)!.AsObject();
Assert.True(node.ContainsKey("ttlMs"));
Assert.Equal(0, node["ttlMs"]!.GetValue<long>());
Assert.Equal("private", node["cacheScope"]!.GetValue<string>());
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive);
Assert.Equal(CacheScope.Private, deserialized.CacheScope);
}
[Fact]
public static void DiscoverResult_OmitsCachingHints_WhenUnset()
{
var result = NewDiscoverResult();
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
var node = JsonNode.Parse(json)!.AsObject();
// Backward compatibility: servers that do not set the hints must not emit them.
Assert.False(node.ContainsKey("ttlMs"));
Assert.False(node.ContainsKey("cacheScope"));
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
Assert.Null(deserialized.TimeToLive);
Assert.Null(deserialized.CacheScope);
}
[Fact]
public static void DiscoverResult_DeserializesMissingHints_AsNull()
{
// A response from a pre-PR-#2855 server may omit both fields. Deserialization must succeed
// and surface them as null so callers can apply their own defaults.
string json =
"""
{
"supportedVersions": ["2025-11-25"],
"capabilities": {},
"serverInfo": {"name": "x", "version": "1"}
}
""";
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
Assert.Null(deserialized.TimeToLive);
Assert.Null(deserialized.CacheScope);
}
[Fact]
public static void DiscoverResult_DeserializesUnknownCacheScope_AsNull()
{
// A future or unknown cacheScope string must not break deserialization of the entire result.
string json =
"""
{
"supportedVersions": ["2025-11-25"],
"capabilities": {},
"serverInfo": {"name": "x", "version": "1"},
"cacheScope": "shared"
}
""";
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
Assert.Null(deserialized.CacheScope);
}
[Fact]
public static void DiscoverResult_ImplementsICacheableResult()
{
// Compile-time assertion that DiscoverResult participates in the shared cacheability surface
// alongside the list/read result types.
Assert.IsAssignableFrom<ICacheableResult>(NewDiscoverResult());
}
}

View file

@ -0,0 +1,67 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;
namespace ModelContextProtocol.Tests.Protocol;
/// <summary>
/// Serialization tests for the error data payloads introduced by the draft protocol revision (SEP-2575).
/// </summary>
public static class DraftErrorDataTests
{
[Fact]
public static void UnsupportedProtocolVersionErrorData_SerializationRoundTrip_PreservesAllProperties()
{
var original = new UnsupportedProtocolVersionErrorData
{
Supported = new List<string> { "2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25" },
Requested = "2026-07-28",
};
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<UnsupportedProtocolVersionErrorData>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
Assert.Equal(4, deserialized.Supported.Count);
Assert.Contains("2025-11-25", deserialized.Supported);
Assert.Equal("2026-07-28", deserialized.Requested);
}
[Fact]
public static void MissingRequiredClientCapabilityErrorData_SerializationRoundTrip_PreservesAllProperties()
{
var original = new MissingRequiredClientCapabilityErrorData
{
RequiredCapabilities = new ClientCapabilities
{
Sampling = new SamplingCapability(),
},
};
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<MissingRequiredClientCapabilityErrorData>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
Assert.NotNull(deserialized.RequiredCapabilities.Sampling);
}
[Fact]
public static void UnsupportedProtocolVersionException_ExposesRequestedAndSupported()
{
var ex = new UnsupportedProtocolVersionException("2099-12-31", ["2025-11-25", "2025-06-18"]);
Assert.Equal(McpErrorCode.UnsupportedProtocolVersion, ex.ErrorCode);
Assert.Equal("2099-12-31", ex.Requested);
Assert.Equal(2, ex.Supported.Count);
Assert.Contains("2025-11-25", ex.Supported);
}
[Fact]
public static void MissingRequiredClientCapabilityException_ExposesRequiredCapabilities()
{
var caps = new ClientCapabilities { Roots = new RootsCapability() };
var ex = new MissingRequiredClientCapabilityException(caps);
Assert.Equal(McpErrorCode.MissingRequiredClientCapability, ex.ErrorCode);
Assert.Same(caps, ex.RequiredCapabilities);
}
}

View file

@ -287,6 +287,8 @@ public partial class ElicitationTypedTests : ClientServerTestBase
var ex = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.CallToolAsync("TestElicitationNullablePropertyForm", cancellationToken: TestContext.Current.CancellationToken));
Assert.Contains("Nullable", ex.Message);
}
[Fact]
@ -340,7 +342,7 @@ public partial class ElicitationTypedTests : ClientServerTestBase
public sealed class NullablePropertyForm
{
public string? FirstName { get; set; }
public int ZipCode { get; set; }
public int? ZipCode { get; set; }
public bool IsAdmin { get; set; }
}

View file

@ -760,4 +760,71 @@ public static class JsonRpcMessageConverterTests
var error = (JsonRpcError)message;
Assert.NotNull(error.Error.Data);
}
[Fact]
public static void Deserialize_ErrorWithNullId_IsValid()
{
// Per JSON-RPC 2.0 §5.1, when an error occurs before the request id can be determined
// (parse error or invalid request), the server MUST respond with id=null. This shape is
// produced by some peers (e.g. Python's simple-streamablehttp-stateless on a draft probe)
// and must be accepted so the HTTP-fallback path can recognize the structured signal.
string json = """{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Bad Request"}}""";
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(message);
var error = Assert.IsType<JsonRpcError>(message);
Assert.Equal(default(RequestId), error.Id);
Assert.Equal(-32600, error.Error.Code);
Assert.Equal("Bad Request", error.Error.Message);
}
[Fact]
public static void Deserialize_ErrorWithMissingId_IsValid()
{
// Some peers omit `id` entirely on pre-routing errors; treat as null per JSON-RPC 2.0 §5.1.
string json = """{"jsonrpc":"2.0","error":{"code":-32700,"message":"Parse error"}}""";
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(message);
var error = Assert.IsType<JsonRpcError>(message);
Assert.Equal(default(RequestId), error.Id);
Assert.Equal(-32700, error.Error.Code);
}
[Fact]
public static void Deserialize_RequestWithExplicitNullId_Throws()
{
// A message carrying a `method` and an explicit `id:null` is a malformed request. Per the MCP
// base protocol the request id "MUST NOT be null", and a null id does NOT denote a notification
// (JSON-RPC 2.0 notifications omit the id member entirely). The converter must reject it rather
// than silently downgrading to a notification (which would swallow the id and skip the response).
string json = """{"jsonrpc":"2.0","id":null,"method":"tools/list"}""";
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions));
}
[Fact]
public static void Deserialize_RequestWithExplicitNullIdAndParams_Throws()
{
string json = """{"jsonrpc":"2.0","id":null,"method":"tools/call","params":{"name":"echo"}}""";
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions));
}
[Fact]
public static void Deserialize_NotificationWithoutIdMember_IsNotConfusedWithNullIdRequest()
{
// Contrast with the explicit-null-id case above: omitting the id member entirely is a valid
// notification and must continue to deserialize as one.
string json = """{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}""";
var message = JsonSerializer.Deserialize<JsonRpcMessage>(json, McpJsonUtilities.DefaultOptions);
var notification = Assert.IsType<JsonRpcNotification>(message);
Assert.Equal("notifications/cancelled", notification.Method);
}
}

View file

@ -35,4 +35,32 @@ public class RequestIdTests
Assert.Equal(id, JsonSerializer.Deserialize<RequestId>(JsonSerializer.Serialize(id, McpJsonUtilities.DefaultOptions), McpJsonUtilities.DefaultOptions));
}
[Fact]
public void Null_DeserializesAsDefault()
{
// Per JSON-RPC 2.0 §5.1, error responses produced before the request id can be determined
// MUST carry id=null. Deserialization needs to tolerate that shape so callers can handle
// such error envelopes (instead of throwing on the bare RequestId conversion).
var id = JsonSerializer.Deserialize<RequestId>("null", McpJsonUtilities.DefaultOptions);
Assert.Equal(default(RequestId), id);
Assert.Null(id.Id);
}
[Fact]
public void Null_SerializesAsJsonNull()
{
// The default RequestId (Id == null) is the id-less-error-response shape. It MUST serialize as
// JSON null — not "" — so the wire form is spec-conformant and round-trips losslessly.
Assert.Equal("null", JsonSerializer.Serialize(default(RequestId), McpJsonUtilities.DefaultOptions));
}
[Fact]
public void Null_Roundtrips()
{
var json = JsonSerializer.Serialize(default(RequestId), McpJsonUtilities.DefaultOptions);
var id = JsonSerializer.Deserialize<RequestId>(json, McpJsonUtilities.DefaultOptions);
Assert.Equal(default(RequestId), id);
Assert.Null(id.Id);
}
}

View file

@ -0,0 +1,63 @@
using ModelContextProtocol.Protocol;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace ModelContextProtocol.Tests.Protocol;
/// <summary>
/// Serialization tests for the subscriptions/listen types introduced by the draft protocol revision (SEP-2575).
/// </summary>
public static class SubscriptionsListenProtocolTests
{
[Fact]
public static void SubscriptionsListenRequestParams_SerializationRoundTrip_PreservesAllProperties()
{
var original = new SubscriptionsListenRequestParams
{
Notifications = new SubscriptionsListenNotifications
{
ToolsListChanged = true,
PromptsListChanged = true,
ResourcesListChanged = true,
ResourceSubscriptions = new List<string> { "file:///foo.txt", "file:///bar.txt" },
},
Meta = new JsonObject
{
[MetaKeys.ProtocolVersion] = "2026-07-28",
[MetaKeys.LogLevel] = "info",
},
};
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<SubscriptionsListenRequestParams>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
Assert.True(deserialized.Notifications.ToolsListChanged);
Assert.True(deserialized.Notifications.PromptsListChanged);
Assert.True(deserialized.Notifications.ResourcesListChanged);
Assert.NotNull(deserialized.Notifications.ResourceSubscriptions);
Assert.Equal(["file:///foo.txt", "file:///bar.txt"], deserialized.Notifications.ResourceSubscriptions);
Assert.Equal("2026-07-28", (string)deserialized.Meta![MetaKeys.ProtocolVersion]!);
}
[Fact]
public static void SubscriptionsAcknowledgedNotificationParams_SerializationRoundTrip_PreservesNotifications()
{
var original = new SubscriptionsAcknowledgedNotificationParams
{
Notifications = new SubscriptionsListenNotifications
{
ToolsListChanged = true,
},
};
var json = JsonSerializer.Serialize(original, McpJsonUtilities.DefaultOptions);
var deserialized = JsonSerializer.Deserialize<SubscriptionsAcknowledgedNotificationParams>(json, McpJsonUtilities.DefaultOptions);
Assert.NotNull(deserialized);
Assert.True(deserialized.Notifications.ToolsListChanged);
Assert.Null(deserialized.Notifications.PromptsListChanged);
Assert.Null(deserialized.Notifications.ResourcesListChanged);
Assert.Null(deserialized.Notifications.ResourceSubscriptions);
}
}