Add SEP-2549 caching hints (ttlMs and cacheScope) to cacheable results#1623
Add SEP-2549 caching hints (ttlMs and cacheScope) to cacheable results#1623tarekgh wants to merge 7 commits into
Conversation
Implements SEP-2549 "TTL for List Results", which lets servers attach optional caching freshness hints to the five cacheable result types: tools/list, prompts/list, resources/list, resources/templates/list, and resources/read. Protocol changes: - Add ICacheableResult with TimeToLive (serialized as integer-millisecond ttlMs) and CacheScope (serialized as cacheScope). - Add the CacheScope enum (public, private) with lowercase wire values. - Implement the interface on the five cacheable result types. - Register CacheScope for source-generated serialization. Both fields are optional and omitted when unset, so the change is fully backward compatible and requires no capability negotiation. The SDK propagates the values without consuming them. Robustness and security: - ttlMs deserialization clamps out-of-range, fractional, and overflowing values (including positive and negative infinity) to TimeSpan.MinValue or MaxValue instead of throwing, so a malformed or hostile hint cannot break reading of the enclosing result. The shared TimeSpanMillisecondsConverter uses the non-throwing TryGetDouble and clamps by token sign, giving identical behavior on .NET and on .NET Framework (whose number parser reports failure on overflow rather than returning infinity). - cacheScope deserialization tolerates unknown or future values by mapping them to null (treated as the public default) instead of failing the whole result, and matches the known values case-insensitively so a mis-cased "private" is honored rather than silently downgraded to public. Tests: - Serialization, round-trip, omission, and clamping edge cases for ttlMs. - Unknown, partial, and case-insensitive cacheScope handling. - Per-page independence of caching hints for pagination. - End-to-end propagation of hints from server to client. - Regression coverage for the shared converter used by McpTask ttl and pollInterval. - Caching conformance scenario wiring, gated to the conformance build that provides it. Verified across net8.0, net9.0, net10.0, and net472, and under Native AOT publish with no trimming or AOT warnings.
There was a problem hiding this comment.
Pull request overview
Adds SEP-2549 caching hints to the C# MCP SDK protocol DTOs so servers can attach optional TTL (ttlMs) and cache scoping (cacheScope) metadata to cacheable results, with hardened deserialization and conformance wiring to validate behavior end-to-end.
Changes:
- Introduces
ICacheableResult+CacheScopeand implementsttlMs/cacheScopeon the five cacheable result DTOs. - Hardens
TimeSpanMillisecondsConverterto clamp out-of-range millisecond values instead of throwing, and adds broad regression/edge-case tests. - Extends the conformance server/tests infrastructure to support draft stateless lifecycle runs and a gated caching conformance scenario.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/ModelContextProtocol.Tests/Protocol/TimeSpanMillisecondsConverterSharedTests.cs | Regression tests ensuring hardened millisecond TimeSpan parsing still preserves existing McpTask behavior. |
| tests/ModelContextProtocol.Tests/Protocol/CacheableResultTests.cs | Unit tests covering serialization/omission/round-trip and hostile-input handling for ttlMs + cacheScope. |
| tests/ModelContextProtocol.Tests/Protocol/CacheableResultClientServerTests.cs | End-to-end client/server propagation tests for caching hints. |
| tests/ModelContextProtocol.ConformanceServer/Program.cs | Adds stateless server mode switch and applies caching hints via filters for conformance scenarios. |
| tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs | Refactors server conformance runner invocation and adds stateless server usage for draft SEP-2243 scenarios. |
| tests/ModelContextProtocol.AspNetCore.Tests/ClientConformanceTests.cs | Updates skip messaging for SEP-2243 scenario availability. |
| tests/ModelContextProtocol.AspNetCore.Tests/CachingConformanceTests.cs | New gated conformance test + stateless server helper for the draft caching scenario. |
| tests/Common/Utils/NodeHelpers.cs | Enhances conformance runner plumbing and gates scenarios based on installed conformance package version. |
| src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs | Clamps oversized/fractional millisecond inputs during deserialization to avoid throwing on hostile values. |
| src/ModelContextProtocol.Core/Protocol/ReadResourceResult.cs | Adds ttlMs/cacheScope properties and implements ICacheableResult. |
| src/ModelContextProtocol.Core/Protocol/ListToolsResult.cs | Adds ttlMs/cacheScope properties and implements ICacheableResult. |
| src/ModelContextProtocol.Core/Protocol/ListResourceTemplatesResult.cs | Adds ttlMs/cacheScope properties and implements ICacheableResult. |
| src/ModelContextProtocol.Core/Protocol/ListResourcesResult.cs | Adds ttlMs/cacheScope properties and implements ICacheableResult. |
| src/ModelContextProtocol.Core/Protocol/ListPromptsResult.cs | Adds ttlMs/cacheScope properties and implements ICacheableResult. |
| src/ModelContextProtocol.Core/Protocol/ICacheableResult.cs | New interface defining the cache hint surface area for cacheable results. |
| src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs | New tolerant converter intended to map unknown cacheScope values to null. |
| src/ModelContextProtocol.Core/Protocol/CacheScope.cs | New enum for cache scoping with lowercase wire names. |
| src/ModelContextProtocol.Core/McpJsonUtilities.cs | Registers CacheScope for source-generated serialization. |
- CacheScopeConverter.Read now consumes non-string tokens with reader.Skip() before returning null. Previously an object or array value for cacheScope left the reader mispositioned and threw "read too much or not enough", breaking deserialization of the whole result. Added object and array cases to the tolerant-deserialization test. - GetInstalledConformanceVersion no longer calls EnsureNpmDependenciesInstalled. The version check backs Theory skip gates and must be side-effect-free; it now returns null when the conformance package is absent. The actual scenario run path still restores npm dependencies via ConformanceTestStartInfo.
halter73
left a comment
There was a problem hiding this comment.
It might be nice to add some samples or conceptual docs showing how to configure ttlMs and cacheScope on the server and then consume it on the client.
docs/concepts/filters.md already has a caching example (server-side IMemoryCache). Adding a "Client-side caching hints (SEP-2549)" snippet that mirrors this filter pattern might be nice.
| /// <inheritdoc /> | ||
| [JsonPropertyName("ttlMs")] | ||
| [JsonConverter(typeof(TimeSpanMillisecondsConverter))] | ||
| public TimeSpan? TimeToLive { get; set; } |
There was a problem hiding this comment.
This and cacheScope are now required fields with the new protocol version. I assume we want to send defaults if this is unspecified with the draft protocol version selected. I imagine this would be ttlMs: 0 and cacheScope: "private" (immediately stale, not shareable), which preserves today's "don't cache" behavior.
Can we add the logic to send this instead of omitting the fields when they aren't customized? We currently use DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull for MCP DTOs in McpJsonUtilities, but that violates the spec.
And should we do something on the client if a server claiming to support the new protocol omits these fields? Maybe log a warning rather than throw to avoid breaking people unnecessarily when talking to non-conformant servers.
There was a problem hiding this comment.
Done. The server now injects the conservative defaults (ttlMs: 0, cacheScope: "private" -> immediately stale, not shareable) whenever a handler leaves them unset, so the required fields are always present on the wire and today's "don't cache" behavior is preserved. Any value a handler or filter supplies is left untouched.
A couple of notes on the route we took:
-
Injection is done by wrapping the handler in
SetHandlerfor the five cacheable result types (tools/list, prompts/list, resources/list, resources/templates/list, resources/read), rather than changing the JSON ignore condition. This keepsDefaultIgnoreCondition = WhenWritingNullintact for the rest of the DTOs and only forces the fields to be written for the results the spec actually requires them on. The check is a one-timeICacheableResult.IsAssignableFromat registration, so the hot paths are untouched and it stays AOT-safe. -
We kept
TimeToLiveandCacheScopenullable rather than making them non-nullable with default initializers. The send and read directions need different defaults: on send the safe default isprivate(the SEP notes there is no safe default when interpreting older servers, and filtered/user-specific results shouldn't be assumed shareable), but on read an absentcacheScopemust be treated aspublicper the spec. Since the same result type is shared by both client and server, a single non-nullable property with one initializer can't express both. Nullable + server-side injection gives us "server always emits a value, client can still observe absence from another server" without splitting each result into separate send/receive types. (default(CacheScope)is alsoPublic, which would be the wrong default to bake into the type for the send path.)
On the client-side warning when a server advertising the new version omits these fields: that's a reasonable addition; I'd prefer to track it as a follow-up so it can be applied consistently across all the list/read paths.
| /// </para> | ||
| /// </remarks> | ||
| public sealed class ListToolsResult : PaginatedResult | ||
| public sealed class ListToolsResult : PaginatedResult, ICacheableResult |
There was a problem hiding this comment.
The new TimeToLive/CacheScope properties are only reachable through the raw ListToolsAsync(ListToolsRequestParams, CancellationToken) overload. The auto-paginating ListToolsAsync(RequestOptions?, CancellationToken) overload (which is probably what most consumers reach for) returns IList<McpClientTool> and drops the per-page hints entirely. Same story for ListPromptsAsync, ListResourcesAsync, and ListResourceTemplatesAsync. ReadResourceAsync is fine since it isn't paginated.
Do we need any follow up issues for this? I can think of a couple things we might want to do. One would be to expose the TimeToLive/CacheScope to the auto-paginating overload. The second might be to take advantage of the caching opportunities in the client when given a non-zero ttol
In the meantime, adding a small <remarks> note on the convenience overloads pointing at the raw one probably makes sense.
There was a problem hiding this comment.
Added a <remarks> note to all four auto-paginating overloads (ListToolsAsync, ListPromptsAsync, ListResourcesAsync, ListResourceTemplatesAsync) pointing callers at the raw single-page overload, which returns the result type that carries TimeToLive/CacheScope. ReadResourceAsync is left alone since it isn't paginated.
For the two larger ideas, I filed follow-ups and kept them out of this PR:
- Surface the hints on the auto-paginating overloads: Surface SEP-2549 caching hints (ttlMs/cacheScope) on the auto-paginating list overloads #1645
- Client-side caching that honors the TTL/scope: Add optional client-side caching that honors SEP-2549 ttlMs/cacheScope #1646
I'd rather not fold these into this PR. The auto-paginating overloads aggregate multiple pages into a single IList<...>, and each page carries its own ttlMs/cacheScope, so exposing them is a real API-design decision (which value do you surface for an aggregated list, and do we change the return type, add out params, or introduce a new wrapper?) rather than a quick addition. Client-side caching is effectively a new opt-in subsystem (cache storage, TTL expiry, public/private scope semantics, invalidation, concurrency). Keeping this PR focused on the SEP-2549 wire-format conformance keeps the change small and easy to review, and the <remarks> already give callers the path to the hints today.
| /// When this property is <see langword="null"/> (the field was absent from the response), clients | ||
| /// should assume a default of <see cref="TimeSpan.Zero"/> (immediately stale) and rely on their | ||
| /// own caching heuristics or notifications. A negative value should likewise be treated as | ||
| /// <see cref="TimeSpan.Zero"/>. |
There was a problem hiding this comment.
| /// <see cref="TimeSpan.Zero"/>. | |
| /// <see cref="TimeSpan.Zero"/>. The SDK preserves whatever value the server sent. |
There was a problem hiding this comment.
Reworded the TimeToLive remarks. It no longer claims a negative value is coerced to TimeSpan.Zero (the SDK doesn't do that). It now states the SDK preserves whatever value the server sent and that a client receiving a negative value should treat it as immediately stale. Thanks for catching the inaccuracy.
Results that carry caching hints (tools/list, prompts/list, resources/list, resources/templates/list, resources/read) now always emit ttlMs and cacheScope on the wire. When a handler leaves them unset, the server fills in conservative defaults (ttlMs: 0, cacheScope: private) so the required fields are present while preserving today's 'don't cache' behavior. Handler- or filter-supplied values are left untouched. The properties remain nullable so the client can still represent their absence from older or non-conformant servers.
PR #1579 (SEP-2663) replaced the SEP-1686 McpTask type with CreateTaskResult and switched its ttl/pollInterval to bare `long?` properties, so the TimeSpanMillisecondsConverter no longer has a second consumer. The shared regression suite cherry-picked from PR #1623 references the now-removed McpTask type and stops compiling. The converter's clamp-instead-of-throw branches are still fully exercised by CacheableResultTests (oversized, large-negative, +Inf, -Inf round-trips), so no coverage is lost. Drop the file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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>
The auto-paginating ListToolsAsync/ListPromptsAsync/ListResourcesAsync/ ListResourceTemplatesAsync overloads aggregate all pages into a single list and do not surface the per-result ttlMs/cacheScope hints. Add a remarks note on each pointing callers at the raw single-page overload that returns the result type carrying those hints.
The TimeToLive remarks claimed a negative value is treated as TimeSpan.Zero, which the SDK does not actually do. Reword to state the SDK preserves whatever value the server sent and leaves it to the client to treat a negative value as immediately stale.
# Conflicts: # src/ModelContextProtocol.Core/Protocol/TimeSpanMillisecondsConverter.cs
Summary
Implements SEP-2549 "TTL for List Results".
The SEP lets a server attach optional caching hints to the responses that are expensive to recompute and are commonly re-fetched, so a client can keep using a recent response for a bounded period instead of requesting it again. Two hints are added to the five cacheable result types (
tools/list,prompts/list,resources/list,resources/templates/list, andresources/read):ttlMs: how long, in milliseconds, the client may treat the response as fresh.cacheScope: whether the response may be stored by shared caches (public) or only by the requesting user's own client (private).These hints supplement, and do not replace, the existing
list_changedandresources/updatednotifications. A relevant notification still invalidates a cached response regardless of any remaining TTL.What changed
Protocol (
ModelContextProtocol.Core):ICacheableResultinterface exposingTimeSpan? TimeToLive(wire namettlMs) andCacheScope? CacheScope(wire namecacheScope).CacheScopeenum with lowercase wire valuespublicandprivate.CacheScopeis registered for source-generated serialization.Both properties are optional and are omitted from the payload when unset, so the change is backward compatible and needs no capability negotiation. The SDK propagates the values end to end; it does not itself consume them to make caching decisions.
Reliability and security
The hints can come from any server, so deserialization is hardened to never let a malformed or hostile value break reading of the enclosing result:
ttlMsvalues that are out of range, fractional, or that overflow (including positive and negative infinity) are clamped toTimeSpan.MinValueorTimeSpan.MaxValuerather than throwing. The sharedTimeSpanMillisecondsConverterreads with the non-throwingTryGetDoubleand clamps by the sign of the raw token, so behavior is identical on modern .NET (where an out-of-range number parses to infinity) and on .NET Framework (where the parser reports failure on overflow).cacheScopevalues that are unknown or added by a future revision are tolerated and surfaced asnull(which clients treat as thepublicdefault) instead of failing the whole result. Matching is case-insensitive on read so a mis-casedprivate, a security-relevant hint, is honored rather than silently downgraded to public. Output is always the exact lowercase spec value.Tests
ttlMs.cacheScope.McpTaskttlandpollInterval.Verified across
net8.0,net9.0,net10.0, andnet472, and under a Native AOT publish of the AOT compatibility test app with no trimming or AOT warnings.