List TTL (SEP-2549)

Server-emitted cache-freshness hints on every list endpoint; three-state ttlMs encoding.

SEP-2549 List TTL — Cache Hints on List and Read Results

Stable — implements SEP-2549 (List TTL), merged into the MCP spec.

Demonstrates the SEP-2549 cache hints — ttlMs (integer milliseconds) and
cacheScope (public/private) — that an MCP server attaches to every
paginated list response (tools/list, prompts/list, resources/list,
resources/templates/list) and to resources/read. Clients use them to
cache the registered surface between notifications/list_changed instead
of re-fetching on every poll.

Spec: SEP-2549.
Migration notes: docs/LIST_TTL_MIGRATION.md.

ttlMs contract

State Wire shape Semantics
Immediately stale field absent, or "ttlMs": 0 client MAY re-fetch every time the result is needed
Fresh for N "ttlMs": <positive int> client SHOULD cache for N milliseconds; invalidate on list_changed

The merged spec treats an absent ttlMs the same as 0. mcpkit still
encodes the field as *int + omitempty so a server can emit an explicit
"ttlMs": 0 distinct from omitting it — clients behave identically either
way, but the explicit form states the intent on the wire.

cacheScope contract

Value Wire shape Semantics
Public "cacheScope": "public" any client or shared proxy MAY cache and serve to any user
Private "cacheScope": "private" cache only within one authorization context; never share across access tokens
Absent field omitted clients default to public

A server whose response varies per caller MUST set private explicitly —
see the security note in the migration guide.

Quick Start

# Terminal 1 — start the MCP server (ttlMs=60000)
just serve

# Terminal 2 — scripted walkthrough
just demo

# Or for the interactive TUI:
go run . --tui

What it demonstrates

  • The ttlMs and cacheScope fields surfacing on every paginated list response and on resources/read.
  • The ttlMs contract on the wire: absent / "ttlMs": 0 (both immediately stale) and "ttlMs": <positive> (fresh for N ms).
  • Server-side configuration via server.WithListCacheControl(ttlMs, scope) and server.WithReadResourceCacheControl(ttlMs, scope) — uniform across endpoints, with negative ttlMs omitting the field.
  • Client-side typed helpers — client.ListToolsPage, ListPromptsPage, ListResourcesPage, ListResourceTemplatesPage, ReadResourceFull — that return the result envelope so callers can read TTLMs and CacheScope.
  • Direct raw-JSON inspection to confirm the wire shape (ttlMs is a JSON number, cacheScope a string).

See WALKTHROUGH.md for the full sequence diagram and
step-by-step description.

Other states

The default just serve runs with ttlMs=60000 (the “fresh for 60s”
state). To exercise the other modes:

Command Mode
go run . --serve --ttl-ms=0 immediately stale — emits "ttlMs": 0
go run . --serve --ttl-ms=-1 unset — ttlMs field omitted entirely
go run . --serve (no --ttl-ms) unset (same as --ttl-ms=-1)
go run . --serve --ttl-ms=60000 --cache-scope=private fresh for 60s, cacheScope: private

The = form is required because Go’s flag package treats --ttl-ms -1
as --ttl-ms followed by an unknown flag -1 — Makefile recipes spawning
this fixture should always use --ttl-ms=N (or omit the flag entirely for
the unset default).

Conformance fixture

The same binary is reused as the fixture for the SEP-2549 conformance
suite on the panyam/mcpconformance pending branch.
The runner spawns three independent processes (one per ttlMs state) in
parallel so all the wire shapes flow through a real dispatcher in a single
test run. The dedicated testconf-list-ttl target was retired when SEP-2549
coverage moved to upstream’s CachingScenario — it now runs as part of the
default just testconf suite.

Where to look in the code

What Where
Server options server.WithListTTLMs / WithListCacheControl / WithReadResourceCacheControl
Wire types core.ToolsListResult, PromptsListResult (core/prompt.go), ResourcesListResult / ResourceTemplatesListResult / ResourceResult (core/resource.go), CacheScope* constants (core/cache.go)
Client helpers client.ListToolsPage and siblings, client.ReadResourceFull
Migration guide docs/LIST_TTL_MIGRATION.md
Conformance SEP-2549 scenarios on panyam/mcpconformance pending — superseded by upstream’s CachingScenario, covered by just testconf
SEP SEP-2549 spec PR

Next steps


Walkthrough

MCP List TTL (SEP-2549) — Cache Hints on List and Read Results

Walks through SEP-2549, which adds two cache hints — ttlMs (integer milliseconds) and cacheScope (public/private) — to every paginated list response (tools/list, prompts/list, resources/list, resources/templates/list) and to resources/read. Clients use them to cache the registered surface between notifications/list_changed instead of re-fetching on every poll.

What you’ll learn

  • Connect to the list-ttl serverclient.NewClient(...) + Connect(). SEP-2549 is purely a server-side concern; the client doesn’t negotiate anything special.
  • tools/list — cache hints surface on the list responseclient.ListToolsPage("") returns the full envelope including TTLMs *int and CacheScope string.
  • prompts/list / resources/list / resources/templates/list — SEP-2549 applies to every paginated list response. WithListTTLMs / WithListCacheControl configure the values uniformly — there’s no per-endpoint override. Hit each endpoint and confirm they all return the configured hints.
  • resources/read — cache hints on a read response — SEP-2549 added resources/read to the cacheable coverage mid-cycle. client.ReadResourceFull returns core.ResourceResult, which carries the same TTLMs / CacheScope fields. A read handler MAY override either per-read; otherwise the WithReadResourceCacheControl server default applies.
  • Inspect the raw JSON-RPC envelope — Bypass the typed helper and decode the raw response body to verify the wire shape — "ttlMs": 60000 as a JSON number and "cacheScope" as a string, sitting alongside "tools" and (when paginated) "nextCursor".

Flow

sequenceDiagram
    participant Host as MCP Host (this client)
    participant Server as MCP Server (just serve, WithListTTLMs(60000))

    Note over Host,Server: Step 1: Connect to the list-ttl server
    Host->>Server: POST /mcp — initialize
    Server-->>Host: serverInfo + capabilities

    Note over Host,Server: Step 2: tools/list — cache hints surface on the list response
    Host->>Server: tools/list
    Server-->>Host: { tools: [...], ttlMs: 60000 }

    Note over Host,Server: Step 3: prompts/list / resources/list / resources/templates/list
    Host->>Server: (same cache-hint contract on all four list endpoints)

    Note over Host,Server: Step 4: resources/read — cache hints on a read response
    Host->>Server: resources/read file:///fixture
    Server-->>Host: { contents: [...], ttlMs: 60000 }

    Note over Host,Server: Step 5: Inspect the raw JSON-RPC envelope
    Host->>Server: tools/list (raw)
    Server-->>Host: raw JSON: ttlMs + cacheScope wire-format check

Steps

Setup

Start the MCP server in a separate terminal first:

Terminal 1:  just serve         # list-ttl server on :8080 with WithListTTLMs(60000)
Terminal 2:  just demo          # this walkthrough (--tui for the interactive TUI)

The ttlMs cache hint

The ttlMs field is an integer-milliseconds freshness hint. Per the merged SEP-2549 spec it has two client-visible behaviors:

  • absent or "ttlMs": 0 — the response is immediately stale; the client MAY re-fetch every time the list is needed. An absent field is the “older server / not configured” case; clients treat it the same as 0.
  • "ttlMs": <positive int> — fresh for N milliseconds from receipt; the client SHOULD NOT re-fetch before it expires unless it receives list_changed.

Server-side, mcpkit.WithListTTLMs(ms) configures the value uniformly for all four list endpoints. Negative values are treated as “unset” so the wire field is omitted. mcpkit keeps TTLMs a *int: that lets a server emit an explicit "ttlMs": 0 distinct from omitting the field, even though clients treat the two the same.

Client-side, mcpkit/client.ListToolsPage(cursor) and its three siblings (ListPromptsPage, ListResourcesPage, ListResourceTemplatesPage) return the typed result envelope so callers can read TTLMs and CacheScope alongside NextCursor. The pre-existing zero-arg ListTools() and the auto-paginating Tools(ctx) iterator drop the envelope — use the *Page helpers when the cache hints matter.

The cacheScope hint

The cacheScope field controls who may serve a cached copy of a response, mirroring HTTP Cache-Control: public vs private:

  • "public" — no caller-specific data; any client, shared gateway, or caching proxy MAY store the response and serve it to any user.
  • "private" — caller-specific data; a cache MAY be reused only within the same authorization context and MUST NOT be shared across access tokens.

When cacheScope is absent clients default to "public", so a server whose response varies per caller MUST set private explicitly. Set both hints in one call with server.WithListCacheControl(ttlMs, scope).

Step 1: Connect to the list-ttl server

client.NewClient(...) + Connect(). SEP-2549 is purely a server-side concern; the client doesn’t negotiate anything special.

Reproduce on the wire

# Initialize a session and capture the session id. SEP-2549 negotiates
# nothing special — a plain initialize is enough.
SID=$(curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":"i","method":"initialize","params":{"protocolVersion":"2025-11-25","clientInfo":{"name":"x","version":"1"},"capabilities":{}}}' \
  -D - -o /dev/null | grep -i 'mcp-session-id' | awk '{print $2}' | tr -d '\r\n')
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' >/dev/null
echo "SID=$SID"

Step 2: tools/list — cache hints surface on the list response

client.ListToolsPage("") returns the full envelope including TTLMs *int and CacheScope string.

Reproduce on the wire

# tools/list — the cache hints ride alongside "tools" in the result.
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
  | jq '{ttlMs: .result.ttlMs, cacheScope: .result.cacheScope, count: (.result.tools | length)}'

Step 3: prompts/list / resources/list / resources/templates/list

SEP-2549 applies to every paginated list response. WithListTTLMs / WithListCacheControl configure the values uniformly — there’s no per-endpoint override. Hit each endpoint and confirm they all return the configured hints.

Reproduce on the wire

# Same cache-hint contract on the other three list endpoints.
for M in prompts/list resources/list resources/templates/list; do
  curl -s -X POST http://localhost:8080/mcp \
    -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
    -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$M\",\"params\":{}}" \
    | jq "{method: \"$M\", ttlMs: .result.ttlMs, cacheScope: .result.cacheScope}"
done

Step 4: resources/read — cache hints on a read response

SEP-2549 added resources/read to the cacheable coverage mid-cycle. client.ReadResourceFull returns core.ResourceResult, which carries the same TTLMs / CacheScope fields. A read handler MAY override either per-read; otherwise the WithReadResourceCacheControl server default applies.

Reproduce on the wire

# resources/read carries the same hints (SEP-2549 added it mid-cycle).
curl -s -X POST http://localhost:8080/mcp \
  -H 'Content-Type: application/json' -H 'Accept: text/event-stream, application/json' -H "Mcp-Session-Id: $SID" \
  -d '{"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"file:///fixture"}}' \
  | jq '{ttlMs: .result.ttlMs, cacheScope: .result.cacheScope, contents: (.result.contents | length)}'

Step 5: Inspect the raw JSON-RPC envelope

Bypass the typed helper and decode the raw response body to verify the wire shape — "ttlMs": 60000 as a JSON number and "cacheScope" as a string, sitting alongside "tools" and (when paginated) "nextCursor".

Caching pattern

A typical client integrates the hints like this:

page, err := c.ListToolsPage("")
if err != nil { /* ... */ }
cache.Tools = page.Tools
if page.TTLMs != nil && *page.TTLMs > 0 {
    cache.ToolsExpiresAt = time.Now().Add(time.Duration(*page.TTLMs) * time.Millisecond)
}
// Subsequent reads check cache.ToolsExpiresAt; on miss, re-fetch.
// On notifications/list_changed, invalidate immediately regardless of TTL.

An absent TTLMs and *page.TTLMs == 0 both mean “immediately stale — do not rely on this response being fresh”. A private cacheScope means the entry MUST NOT be reused across authorization contexts; key any shared cache by access token.

Where to look in the code

  • Server options: server.WithListTTLMs / WithListCacheControl / WithReadResourceCacheControl — server/server.go
  • Wire types: core.ToolsListResult / PromptsListResult / ResourcesListResult / ResourceTemplatesListResult / ResourceResult — core/{tool,prompt,resource}.go; core.CacheScopePublic / CacheScopePrivate — core/cache.go
  • Client typed helpers: client.ListToolsPage / ListPromptsPage / ListResourcesPage / ListResourceTemplatesPage / ReadResource — client/iterators.go
  • Migration guide: docs/LIST_TTL_MIGRATION.md
  • Conformance: SEP-2549 scenarios on panyam/mcpconformance pending (src/scenarios/server/list-ttl/) — originally driven by a dedicated testconf-list-ttl suite, now folded into just testconf
  • SEP-2549 spec: https://github.com/modelcontextprotocol/specification/pull/2549

Run it

go run ./examples/list-ttl/

Pass --non-interactive to skip pauses:

go run ./examples/list-ttl/ --non-interactive