List TTL (SEP-2549)
Server-emitted cache-freshness hints on every list endpoint; three-state ttlMs encoding.
Source: examples/list-ttl/README.md
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
ttlMsandcacheScopefields surfacing on every paginated list response and onresources/read. - The
ttlMscontract on the wire: absent /"ttlMs": 0(both immediately stale) and"ttlMs": <positive>(fresh for N ms). - Server-side configuration via
server.WithListCacheControl(ttlMs, scope)andserver.WithReadResourceCacheControl(ttlMs, scope)— uniform across endpoints, with negativettlMsomitting the field. - Client-side typed helpers —
client.ListToolsPage,ListPromptsPage,ListResourcesPage,ListResourceTemplatesPage,ReadResourceFull— that return the result envelope so callers can readTTLMsandCacheScope. - Direct raw-JSON inspection to confirm the wire shape (
ttlMsis a JSON number,cacheScopea 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
Generated from the scripted demo — run it with make serve + make demo. Source: examples/list-ttl/WALKTHROUGH.md
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 server —
client.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 response —
client.ListToolsPage("")returns the full envelope includingTTLMs *intandCacheScope string. - prompts/list / resources/list / resources/templates/list — SEP-2549 applies to every paginated list response.
WithListTTLMs/WithListCacheControlconfigure 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.ReadResourceFullreturnscore.ResourceResult, which carries the sameTTLMs/CacheScopefields. A read handler MAY override either per-read; otherwise theWithReadResourceCacheControlserver default applies. - Inspect the raw JSON-RPC envelope — Bypass the typed helper and decode the raw response body to verify the wire shape —
"ttlMs": 60000as 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 receiveslist_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 dedicatedtestconf-list-ttlsuite, now folded intojust 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