Skip to content

Commit 8ff1735

Browse files
halter73Copilot
andcommitted
Add ttlMs and cacheScope to DiscoverResult per spec PR #2855
SEP-2549 (PR #1623 cherry-picked in 51571c6) added the ICacheableResult contract with tlMs and cacheScope to the five list/read results, but the spec was subsequently amended by spec PR #2855 to also require both fields on server/discover responses. Implement that on DiscoverResult and emit safe defaults from the built-in handler so existing servers keep their "do not cache" behavior while remaining wire-compliant under draft. Changes: - `DiscoverResult` now implements `ICacheableResult` and carries `TimeToLive`/`CacheScope` properties with the same wire shape as the list/read results. - `ICacheableResult` xmldoc updated to mention `server/discover` alongside the existing list/read implementers. - `McpServerImpl.ConfigureDiscover` emits `ttlMs: 0` + `cacheScope: "private"` (immediately stale, not shareable) on the built-in handler. The values match halter73's design call on PR #1623: the safest defaults preserve today's behavior without requiring server authors to opt-in to caching, while still satisfying the wire requirement under draft. - `RawHttpConformanceTests.ServerDiscover_RawPost_ReturnsDiscoverResult` and `RawStreamConformanceTests.ServerDiscover_ReturnsSupportedVersionsIncludingDraft` now assert the fields are emitted with the expected values. - New `DiscoverResultCacheableTests` exercises the round-trip on `DiscoverResult` (the existing parameterized `CacheableResultTests` cannot cover it because `DiscoverResult` has required CLR properties that block reflection-based `Activator.CreateInstance`). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ed8561f commit 8ff1735

6 files changed

Lines changed: 169 additions & 3 deletions

File tree

src/ModelContextProtocol.Core/Protocol/DiscoverResult.cs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ namespace ModelContextProtocol.Protocol;
1111
/// to learn what a server supports without performing the legacy <c>initialize</c> handshake.
1212
/// </para>
1313
/// </remarks>
14-
public sealed class DiscoverResult : Result
14+
public sealed class DiscoverResult : Result, ICacheableResult
1515
{
1616
/// <summary>
1717
/// Gets or sets the list of MCP protocol version strings that the server supports.
@@ -43,4 +43,25 @@ public sealed class DiscoverResult : Result
4343
/// </remarks>
4444
[JsonPropertyName("instructions")]
4545
public string? Instructions { get; set; }
46+
47+
/// <inheritdoc />
48+
/// <remarks>
49+
/// Spec PR #2855 makes <c>ttlMs</c> a required field on <see cref="DiscoverResult"/>. The
50+
/// server emits a safe default (<see cref="TimeSpan.Zero"/>, i.e. immediately stale) on
51+
/// draft sessions when the application has not set an explicit value, preserving today's
52+
/// "do not cache" behavior while satisfying the wire requirement.
53+
/// </remarks>
54+
[JsonPropertyName("ttlMs")]
55+
[JsonConverter(typeof(TimeSpanMillisecondsConverter))]
56+
public TimeSpan? TimeToLive { get; set; }
57+
58+
/// <inheritdoc />
59+
/// <remarks>
60+
/// Spec PR #2855 makes <c>cacheScope</c> a required field on <see cref="DiscoverResult"/>. The
61+
/// server emits a safe default (<see cref="Protocol.CacheScope.Private"/>) on draft sessions
62+
/// when the application has not set an explicit value.
63+
/// </remarks>
64+
[JsonPropertyName("cacheScope")]
65+
[JsonConverter(typeof(CacheScopeConverter))]
66+
public CacheScope? CacheScope { get; set; }
4667
}

src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ namespace ModelContextProtocol.Protocol;
77
/// <remarks>
88
/// <para>
99
/// This interface corresponds to the <c>CacheableResult</c> type in the Model Context Protocol
10-
/// schema and is implemented by the results of <c>tools/list</c>, <c>prompts/list</c>,
11-
/// <c>resources/list</c>, <c>resources/templates/list</c>, and <c>resources/read</c>.
10+
/// schema and is implemented by the results of <c>server/discover</c>, <c>tools/list</c>,
11+
/// <c>prompts/list</c>, <c>resources/list</c>, <c>resources/templates/list</c>, and
12+
/// <c>resources/read</c>.
1213
/// </para>
1314
/// <para>
1415
/// The TTL is a freshness hint, not a guarantee. It supplements rather than replaces the existing

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,11 @@ private void ConfigureDiscover(McpServerOptions options)
444444
Capabilities = ServerCapabilities ?? new(),
445445
ServerInfo = options.ServerInfo ?? DefaultImplementation,
446446
Instructions = options.ServerInstructions,
447+
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult. Default to
448+
// the safest values (immediately stale, not shareable) so existing servers keep
449+
// their "do not cache" behavior while satisfying the wire requirement.
450+
TimeToLive = TimeSpan.Zero,
451+
CacheScope = CacheScope.Private,
447452
});
448453
},
449454
McpJsonUtilities.JsonContext.Default.DiscoverRequestParams,

tests/ModelContextProtocol.AspNetCore.Tests/RawHttpConformanceTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using ModelContextProtocol.Tests.Utils;
77
using System.Net;
88
using System.Text;
9+
using System.Text.Json;
910
using System.Text.Json.Nodes;
1011

1112
namespace ModelContextProtocol.AspNetCore.Tests;
@@ -126,6 +127,12 @@ public async Task ServerDiscover_RawPost_ReturnsDiscoverResult()
126127
var json = await ReadJsonResponseAsync(response, TestContext.Current.CancellationToken);
127128
var supported = json["result"]!["supportedVersions"]!.AsArray().Select(n => n!.GetValue<string>()).ToList();
128129
Assert.Contains(DraftVersion, supported);
130+
131+
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
132+
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
133+
Assert.Equal(JsonValueKind.Number, json["result"]!["ttlMs"]!.GetValueKind());
134+
Assert.Equal(0, json["result"]!["ttlMs"]!.GetValue<long>());
135+
Assert.Equal("private", json["result"]!["cacheScope"]!.GetValue<string>());
129136
}
130137

131138
[Fact]
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
using ModelContextProtocol.Protocol;
2+
using System.Text.Json;
3+
using System.Text.Json.Nodes;
4+
5+
namespace ModelContextProtocol.Tests.Protocol;
6+
7+
/// <summary>
8+
/// Targeted tests for the SEP-2549 caching hints (<c>ttlMs</c> and <c>cacheScope</c>) on
9+
/// <see cref="DiscoverResult"/>. Spec PR #2855 promotes both fields to required on the discover
10+
/// response. <see cref="DiscoverResult"/> has <c>required</c> CLR properties for
11+
/// <see cref="DiscoverResult.SupportedVersions"/>, <see cref="DiscoverResult.Capabilities"/>, and
12+
/// <see cref="DiscoverResult.ServerInfo"/>, which prevents reuse of the parameterized
13+
/// <see cref="CacheableResultTests"/> helper (it instantiates via reflection). This file covers the
14+
/// same property-shape assertions for <see cref="DiscoverResult"/>.
15+
/// </summary>
16+
public static class DiscoverResultCacheableTests
17+
{
18+
private static DiscoverResult NewDiscoverResult() => new()
19+
{
20+
SupportedVersions = ["2025-11-25", McpSession.DraftProtocolVersion],
21+
Capabilities = new ServerCapabilities(),
22+
ServerInfo = new Implementation { Name = "test-server", Version = "1.0" },
23+
};
24+
25+
[Fact]
26+
public static void DiscoverResult_SerializesTtlMsAsIntegerMilliseconds()
27+
{
28+
var result = NewDiscoverResult();
29+
result.TimeToLive = TimeSpan.FromMilliseconds(300_000);
30+
result.CacheScope = CacheScope.Public;
31+
32+
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
33+
var node = JsonNode.Parse(json)!.AsObject();
34+
35+
Assert.True(node.ContainsKey("ttlMs"));
36+
Assert.Equal(JsonValueKind.Number, node["ttlMs"]!.GetValueKind());
37+
Assert.Equal(300_000, node["ttlMs"]!.GetValue<long>());
38+
Assert.Equal("public", node["cacheScope"]!.GetValue<string>());
39+
40+
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
41+
Assert.Equal(TimeSpan.FromMilliseconds(300_000), deserialized.TimeToLive);
42+
Assert.Equal(CacheScope.Public, deserialized.CacheScope);
43+
}
44+
45+
[Fact]
46+
public static void DiscoverResult_PrivateScope_RoundTrips()
47+
{
48+
var result = NewDiscoverResult();
49+
result.TimeToLive = TimeSpan.Zero;
50+
result.CacheScope = CacheScope.Private;
51+
52+
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
53+
var node = JsonNode.Parse(json)!.AsObject();
54+
55+
Assert.True(node.ContainsKey("ttlMs"));
56+
Assert.Equal(0, node["ttlMs"]!.GetValue<long>());
57+
Assert.Equal("private", node["cacheScope"]!.GetValue<string>());
58+
59+
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
60+
Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive);
61+
Assert.Equal(CacheScope.Private, deserialized.CacheScope);
62+
}
63+
64+
[Fact]
65+
public static void DiscoverResult_OmitsCachingHints_WhenUnset()
66+
{
67+
var result = NewDiscoverResult();
68+
69+
string json = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions);
70+
var node = JsonNode.Parse(json)!.AsObject();
71+
72+
// Backward compatibility: servers that do not set the hints must not emit them.
73+
Assert.False(node.ContainsKey("ttlMs"));
74+
Assert.False(node.ContainsKey("cacheScope"));
75+
76+
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
77+
Assert.Null(deserialized.TimeToLive);
78+
Assert.Null(deserialized.CacheScope);
79+
}
80+
81+
[Fact]
82+
public static void DiscoverResult_DeserializesMissingHints_AsNull()
83+
{
84+
// A response from a pre-PR-#2855 server may omit both fields. Deserialization must succeed
85+
// and surface them as null so callers can apply their own defaults.
86+
string json =
87+
"""
88+
{
89+
"supportedVersions": ["2025-11-25"],
90+
"capabilities": {},
91+
"serverInfo": {"name": "x", "version": "1"}
92+
}
93+
""";
94+
95+
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
96+
Assert.Null(deserialized.TimeToLive);
97+
Assert.Null(deserialized.CacheScope);
98+
}
99+
100+
[Fact]
101+
public static void DiscoverResult_DeserializesUnknownCacheScope_AsNull()
102+
{
103+
// A future or unknown cacheScope string must not break deserialization of the entire result.
104+
string json =
105+
"""
106+
{
107+
"supportedVersions": ["2025-11-25"],
108+
"capabilities": {},
109+
"serverInfo": {"name": "x", "version": "1"},
110+
"cacheScope": "shared"
111+
}
112+
""";
113+
114+
var deserialized = JsonSerializer.Deserialize<DiscoverResult>(json, McpJsonUtilities.DefaultOptions)!;
115+
Assert.Null(deserialized.CacheScope);
116+
}
117+
118+
[Fact]
119+
public static void DiscoverResult_ImplementsICacheableResult()
120+
{
121+
// Compile-time assertion that DiscoverResult participates in the shared cacheability surface
122+
// alongside the list/read result types.
123+
Assert.IsAssignableFrom<ICacheableResult>(NewDiscoverResult());
124+
}
125+
}

tests/ModelContextProtocol.Tests/Server/RawStreamConformanceTests.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using ModelContextProtocol.Tests.Utils;
66
using System.IO.Pipelines;
77
using System.Text;
8+
using System.Text.Json;
89
using System.Text.Json.Nodes;
910

1011
namespace ModelContextProtocol.Tests.Server;
@@ -101,6 +102,12 @@ public async Task ServerDiscover_ReturnsSupportedVersionsIncludingDraft()
101102
Assert.NotNull(result["capabilities"]);
102103
Assert.NotNull(result["serverInfo"]);
103104
Assert.Equal("raw-conformance-server", result["serverInfo"]!["name"]!.GetValue<string>());
105+
106+
// Spec PR #2855 makes ttlMs and cacheScope required on DiscoverResult; the server emits the
107+
// safest defaults (immediately stale, not shareable) when the application hasn't customized.
108+
Assert.Equal(JsonValueKind.Number, result["ttlMs"]!.GetValueKind());
109+
Assert.Equal(0, result["ttlMs"]!.GetValue<long>());
110+
Assert.Equal("private", result["cacheScope"]!.GetValue<string>());
104111
}
105112

106113
[Fact]

0 commit comments

Comments
 (0)