Skip to content

feat(sep-2549): add absence-assert and explicit-zero wire-distinction checks#310

Open
panyam wants to merge 1 commit into
modelcontextprotocol:mainfrom
panyam:feat/sep-2549-caching-additional-checks
Open

feat(sep-2549): add absence-assert and explicit-zero wire-distinction checks#310
panyam wants to merge 1 commit into
modelcontextprotocol:mainfrom
panyam:feat/sep-2549-caching-additional-checks

Conversation

@panyam
Copy link
Copy Markdown

@panyam panyam commented May 23, 2026

The merged CachingScenario asserts presence of ttlMs and cacheScope on the five cacheable list-response endpoints against a fixture that emits them by default. Two complementary edge cases aren't covered by that single-fixture model:

  1. Servers configured without cache hints SHOULD NOT emit ttlMs or cacheScope (both fields are OPTIONAL per the merged spec). A negative test against a fixture configured with no hints catches servers that emit garbage defaults.
  2. Servers that intentionally surface "explicit zero" vs "no hint at all" need a wire-shape assertion that ttlMs:0 appears on the wire rather than being omitted. The merged spec treats absent ≡ 0 client-side, so this is stricter than the spec mandates - useful for server implementations that deliberately distinguish the two states.

Both checks opt in via secondary fixture URLs (CACHING_NO_HINTS_URL, CACHING_EXPLICIT_ZERO_URL). They emit SKIPPED with a reason when the env var is unset, so the default npm test against the everything-server gets the seven existing checks unchanged. The pattern mirrors TASKS_SERVER_URL / MRTR_SERVER_URL / LIST_TTL_*_URL elsewhere in the suite.

A matching reference implementation lives in mcpkit's examples/list-ttl (three demo servers - positive / explicit-zero / unset). The three fixtures exercise all nine CachingScenario checks (seven existing + two new) by varying the server's caching-hint configuration.

Motivation and Context

SEP-2549 defines ttlMs and cacheScope as OPTIONAL caching hints on cacheable list responses. PR 275 (the merged CachingScenario) covers the happy path where a fixture emits both fields with non-zero TTLs, but leaves two wire-shape edge cases untested:

  • Absence when unset - a server with no cache hints configured shouldn't emit either field. Without a negative test, a server that emits garbage defaults (e.g. ttlMs: 300000 hardcoded into the response builder regardless of configuration) passes conformance silently.
  • Explicit zero distinct from absent - the merged spec treats absent ≡ 0 client-side, but server implementations sometimes need to distinguish the two states on the wire (cache-management observability, step-up flows, immediate-staleness signaling). When such a server is configured to emit explicit ttlMs: 0, the field MUST appear on the wire rather than being omitted.

Both checks have existed downstream in mcpkit's local conformance suite for several weeks; PR 275 making CachingScenario canonical is the natural moment to fold them upstream so they're available to every conformant server.

The TypeScript SDK's current ttlMs?: number type can't represent the explicit-zero distinction (an explicit 0 is indistinguishable from absent at the type level), but SDK ergonomics shouldn't gate what the conformance suite asserts - the suite asserts what the spec permits, and the SDK can catch up. Both checks use raw client.request(..., schema) rather than relying on the SDK's typed accessors, so no SDK-side change is needed.

How Has This Been Tested?

Verified locally against two fixture configurations:

Default npm test (no env vars set, runs against everything-server):

sep-2549-tools-list-caching-hints                 SUCCESS
sep-2549-prompts-list-caching-hints               SUCCESS
sep-2549-resources-list-caching-hints             SUCCESS
sep-2549-resources-templates-list-caching-hints   SUCCESS
sep-2549-resources-read-caching-hints             SUCCESS
sep-2549-ttl-non-negative                         SUCCESS
sep-2549-cache-scope-valid                        SUCCESS
sep-2549-cache-fields-absent-when-unset           SKIPPED  (env var not set)
sep-2549-ttl-ms-explicit-zero-distinct            SKIPPED  (env var not set)

With both env vars set (CACHING_NO_HINTS_URL and CACHING_EXPLICIT_ZERO_URL pointing at the reference mcpkit fixtures in examples/list-ttl):

... (existing 7 same as above, SUCCESS) ...
sep-2549-cache-fields-absent-when-unset           SUCCESS
sep-2549-ttl-ms-explicit-zero-distinct            SUCCESS

npx tsc --noEmit clean. No new tests were added to the conformance suite's own test harness because the new checks are SCENARIO-level (run by the existing all-scenarios.test.ts dispatcher), not standalone unit tests.

Breaking Changes

None. Pure additive coverage. The existing seven CachingScenario checks against the everything-server are untouched and continue to pass on the default npm test run. Both new checks are opt-in via env vars; they SKIPPED-cleanly when the env vars are unset.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

Design decisions:

  • Opt-in via secondary URLs, not a new scenario class. Both edge cases are SEP-2549 wire-shape assertions and belong with the existing CachingScenario. Splitting into a separate caching-edge-cases.ts would force a second connection helper and duplicate the import surface. The env-var-driven secondary URL pattern matches how TASKS_SERVER_URL, MRTR_SERVER_URL, and other multi-fixture suites already work in this repo.
  • SKIPPED-when-not-configured (not FAILURE). Default npm test against the everything-server can't exercise either path (the fixture emits hints with non-zero TTLs), so SKIPPED is the truthful status - neither a pass nor a fail when the fixture doesn't exercise the assertion. FAILURE is reserved for "env var set, connection failed" so a misconfigured opt-in surfaces loudly.
  • Helper duplicates the listing logic. listCachingFieldsForAllEndpoints re-implements the five endpoint calls rather than refactoring the existing inline ones. The duplication keeps the diff scoped to additive lines; refactoring the merged run() to share listing would touch the existing surface more invasively than is warranted for two new checks.
  • TS SDK support isn't a gating factor. SDK ergonomics shouldn't constrain what the conformance suite asserts. Both checks use raw client.request(..., schema) against the existing helpers, so they work today without any SDK changes.

Risk / blast radius:

  • Affects only conformant servers whose maintainer chooses to opt into the secondary fixture URLs. No effect on default test runs.
  • The absence-assert combines both fields (hasTtlMs || hasCacheScope) - a server that emits one but not the other still triggers FAILURE. That's intentional (the assertion is "neither MUST be present"), but worth a sanity check.

Out of scope (deliberately deferred):

  • The everything-server doesn't expose a configuration knob to set up the no-hints or explicit-zero modes. Adding such a knob would let npm test exercise the new checks directly without an out-of-tree fixture; worth a separate PR if there's appetite.
  • A client-side analogue (cache-aware client behavior) would belong in its own scenario class - not in scope here.

… checks

The merged CachingScenario asserts presence of ttlMs and cacheScope on
the five cacheable list-response endpoints against a fixture that emits
them by default. Two complementary edge cases aren't covered by that
single-fixture model:

1. Servers configured without cache hints SHOULD NOT emit ttlMs or
   cacheScope (both fields are OPTIONAL per the merged spec). A negative
   test against a fixture configured with no hints catches servers that
   emit garbage defaults.
2. Servers that intentionally surface "explicit zero" vs "no hint at
   all" need a wire-shape assertion that ttlMs:0 appears on the wire
   rather than being omitted. The merged spec treats absent ≡ 0
   client-side, so this is stricter than the spec mandates — useful for
   server implementations that deliberately distinguish the two states.

Both checks opt in via secondary fixture URLs (CACHING_NO_HINTS_URL,
CACHING_EXPLICIT_ZERO_URL). They emit SKIPPED with a reason when the
env var is unset, so the default `npm test` against the everything-
server gets the seven existing checks unchanged. The pattern mirrors
TASKS_SERVER_URL / MRTR_SERVER_URL / LIST_TTL_*_URL elsewhere in the
suite.

A matching reference implementation lives in mcpkit's examples/list-ttl
(three demo servers — positive / explicit-zero / unset). The three
fixtures exercise all nine CachingScenario checks (seven existing + two
new) by varying the server's caching-hint configuration.
@panyam panyam marked this pull request as ready for review May 23, 2026 17:52
@panyam
Copy link
Copy Markdown
Author

panyam commented May 23, 2026

@CaitieM20 - this is a small additive follow-up to your CachingScenario in PR 275. I added two opt-in checks for the wire-shape edge cases that the single-fixture model doesn't reach: cache-fields-absent-when-unset (catches servers that emit garbage defaults regardless of configuration) and ttl-ms-explicit-zero-distinct (for implementations that intentionally surface the distinction between "no hint" and "explicitly stale" on the wire). Both activate via secondary fixture URLs (CACHING_NO_HINTS_URL, CACHING_EXPLICIT_ZERO_URL) and SKIPPED-cleanly when their env var is unset, so your default npm test path stays exactly as you wrote it.

@panyam
Copy link
Copy Markdown
Author

panyam commented May 23, 2026

@LucaButBoring - cc'ing you as the explicit-zero check has a flavor similar to the retroactive-negative-test conversation we had on PR 262 (where you correctly steered me to drop the notifications/tasks/status absence-assert). Here the wire distinction is intentional and forward-looking, not retroactive - the spec treats absent as 0 client-side, but a server that wants to surface the distinction for cache-management observability needs the assertion. Wanted to see if this matches your read.

One thing worth flagging upfront is that the TypeScript SDK's current ttlMs?: number type can't seem to express the explicit-zero distinction at the type level? Both checks go raw via client.request(..., the explicit-zero distinction at the type level... And both checks go raw via client.request(..., schema)` rather than relying on the typed accessor, so they work today without SDK-side changes. My read/thought is conformance should assert what the spec permits and SDK ergonomics catch up rather than the other way around - but if either of you would rather hold the explicit-zero one until the SDK can express it cleanly, the absence-assert is the cleaner sell on its own and I'm happy to drop the explicit zero check from this PR via a follow-up commit on the branch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant