Skip to content

feat(core,client,server): ttl on list results (SEP-2549)#2070

Draft
felixweinberger wants to merge 3 commits into
mainfrom
fweinberger/f7-list-ttl
Draft

feat(core,client,server): ttl on list results (SEP-2549)#2070
felixweinberger wants to merge 3 commits into
mainfrom
fweinberger/f7-list-ttl

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor


Adds optional ttl?: number to ListToolsResult / ListResourcesResult / ListPromptsResult / ListResourceTemplatesResult. McpServerOptions.listTtlSeconds populates it. Client marks its tool-metadata cache stale after ttl elapses (validators stay enforced; staleness is a re-fetch hint, not a drop-enforcement signal). ttl is clamped to ≥1s.

Motivation and Context

Implements SEP-2549 (list freshness hint).

How Has This Been Tested?

New tests for ttl population + client staleness flag. pnpm test:all passes.

Breaking Changes

None.

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

On main (independent). Review guide: https://gist.github.com/felixweinberger/5a48e0f14d5aced39ed6a91b61940711.


Client: ttl marks the tool-metadata cache stale (validators stay enforced; clamped to >=1s). isToolCacheStale getter for poll-based refresh.
@felixweinberger felixweinberger added the v2-stateless 2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless) label May 12, 2026
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 12, 2026

⚠️ No Changeset found

Latest commit: b8536fd

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 12, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2070

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2070

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2070

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2070

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2070

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2070

commit: b8536fd

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

1 similar comment
@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment thread packages/core/src/types/spec.types.ts Outdated
Comment on lines +90 to +96
/**
* Optional `ttl` (in seconds) included on `tools/list`, `prompts/list`, `resources/list`,
* and `resources/templates/list` responses (SEP-2549). Tells clients how long the response
* may be considered fresh; clients may cache and re-poll on this schedule. Supplements, does
* not replace, the `list_changed` notification mechanism.
*/
listTtlSeconds?: number;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 listTtlSeconds is declared on ServerOptions (the low-level Server option type) but is only consumed by McpServer — a user writing new Server(info, { listTtlSeconds: 60 }) will see this JSDoc promising "included on tools/list ... responses" yet the option is silently ignored, since Server has no built-in list handlers. Consider qualifying the JSDoc with "Only applied by McpServer; low-level Server users must add ttl to list responses manually", or introducing an McpServerOptions extends ServerOptions type for the field.

Extended reasoning...

What the issue is

The new listTtlSeconds option is declared on ServerOptions in packages/server/src/server/server.ts:90-96, which is the constructor-options type for the low-level Server class. However, the Server constructor never reads this field — it reads options.capabilities, options.instructions, and options.jsonSchemaValidator, but not options.listTtlSeconds. The only consumer is McpServer at packages/server/src/server/mcp.ts:82-83, which reads it to populate its built-in list handlers.

This makes listTtlSeconds the first field on ServerOptions that the low-level Server silently ignores, breaking the prior contract that every ServerOptions field is consumed by Server itself.

How it manifests

A user of the low-level API writes:

const server = new Server({ name: 's', version: '1' }, { listTtlSeconds: 60 });
server.setRequestHandler('tools/list', () => ({ tools: [...] }));

IntelliSense surfaces listTtlSeconds with the JSDoc "Optional ttl (in seconds) included on tools/list, prompts/list, resources/list, and resources/templates/list responses" — an unconditional promise. The user reasonably expects ttl: 60 to appear on their list responses. It does not: Server has no built-in list handlers and never injects anything into user-registered handler results, so the option is a silent no-op.

Why existing code doesn't prevent it

There is no separate McpServerOptions type — McpServer's constructor signature is (serverInfo, options?: ServerOptions), so the field had to land on ServerOptions to reach McpServer. That's a reasonable structural choice, but it means the JSDoc is now read by two audiences (low-level Server users and high-level McpServer users) while only being true for one of them. Nothing in the type system or runtime warns low-level users that the option is inert.

Step-by-step proof

  1. Construct new Server(info, { listTtlSeconds: 60 }).
  2. Server constructor (server.ts) assigns _capabilities, _instructions, _jsonSchemaValidator from optionslistTtlSeconds is never referenced.
  3. User calls server.setRequestHandler('tools/list', () => ({ tools: [] })).
  4. Client sends tools/list; Protocol dispatches directly to the user handler. No code path reads listTtlSeconds or spreads a ttl into the result.
  5. Response is { tools: [] } with no ttl, contradicting the JSDoc.

Contrast with McpServer: its constructor stores Math.max(0, Math.round(options.listTtlSeconds)) in _listTtl and spreads ...this._ttl() into every built-in list handler result.

Impact

Low — Server is @deprecated ("Use McpServer instead"), and low-level users author their own list handlers anyway, so Server couldn't auto-inject ttl even if it wanted to. This is a documentation-accuracy / API-placement nit (matching the repo's recurring "prose that promises behavior the code doesn't ship" pattern), not a correctness bug.

Suggested fix

Either:

  • Append to the JSDoc: "Only applied by {@linkcode McpServer}; the low-level Server does not register list handlers, so direct Server users must include ttl on their list responses manually.", or
  • Introduce type McpServerOptions = ServerOptions & { listTtlSeconds?: number } and move the field there, narrowing the type to the class that actually honors it.

Comment on lines +946 to +949
get isToolCacheStale(): boolean {
this._maybeExpireToolCache();
return this._toolCacheStale;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The new public Client.isToolCacheStale getter (and the _maybeExpireToolCache / ≥1s-clamp / reset-on-listTools() logic behind it) has no test coverage — grepping for isToolCacheStale/toolCacheExpiresAt matches only client.ts. The PR description claims "New tests for ttl population + client staleness flag", but the added tests in mcp.test.ts only assert server-side ttl population; a fake-timer test exercising expiry, the clamp, and the stale-flag reset would close the gap.

Extended reasoning...

What's missing

Client gains a new public API surface in this PR:

  • get isToolCacheStale(): boolean — calls _maybeExpireToolCache() then returns _toolCacheStale.
  • _maybeExpireToolCache() — flips _toolCacheStale = true and clears _toolCacheExpiresAt once Date.now() >= _toolCacheExpiresAt.
  • cacheToolMetadata(tools, ttlSeconds?) — now sets _toolCacheExpiresAt = Date.now() + Math.max(1, ttlSeconds) * 1000 (the ≥1s clamp) and resets _toolCacheStale = false on every fresh listTools().

Grepping the repo for isToolCacheStale|toolCacheStale|toolCacheExpiresAt returns only packages/client/src/client/client.ts. No test file — neither packages/client/test/** nor test/integration/** — references the getter or the underlying state.

Why the PR description is misleading

"How Has This Been Tested?" says: "New tests for ttl population + client staleness flag". The diff adds exactly two tests to test/integration/test/server/mcp.test.ts:

  1. should include ttl on list results when listTtlSeconds is configured — asserts result.ttl === 60 on the four list endpoints, calling client.request({ method: '…/list' }) directly (which bypasses Client.listTools() and therefore never touches cacheToolMetadata).
  2. should omit ttl on list results when listTtlSeconds is not configured — asserts the property is absent.

Both are server-side wire-format tests. Neither calls client.listTools(), neither reads client.isToolCacheStale, and neither advances time. The "client staleness flag" half of the claim is not backed by the diff — this is the REVIEW.md recurring catch "prose that promises behavior the code no longer ships."

Step-by-step proof

  1. rg -l 'isToolCacheStale'packages/client/src/client/client.ts (1 hit, the definition).
  2. rg -l 'toolCacheExpiresAt|_toolCacheStale' → same single file.
  3. Inspect the two new mcp.test.ts cases: they use client.request(…) (raw protocol), not client.listTools(), so even cacheToolMetadata(tools, result.ttl) is never reached in those tests.
  4. Therefore none of: (a) isToolCacheStale flipping to true after the ttl elapses, (b) Math.max(1, ttlSeconds) clamping a server-supplied ttl: 0, (c) _toolCacheStale resetting to false on a fresh listTools(), is exercised anywhere.

Why it matters

REVIEW.md checklist: "New behavior has vitest coverage including error paths". isToolCacheStale is a new public getter on the Client class with non-trivial, time-dependent semantics (and a security-adjacent clamp whose comment says "so a server cannot force immediate expiry of the validator cache"). It also currently has zero callsites in the SDK, so tests are the only thing that would catch a regression. Shipping it untested while the PR description asserts otherwise is worth flagging.

Suggested fix

Add a vitest case (e.g. in packages/client/test/client/client.test.ts or alongside the new integration tests) using vi.useFakeTimers():

test('isToolCacheStale tracks SEP-2549 ttl', async () => {
    vi.useFakeTimers();
    const mcpServer = new McpServer({ name: 's', version: '1' }, { listTtlSeconds: 0 }); // exercises the ≥1s clamp
    mcpServer.registerTool('t', {}, async () => ({ content: [] }));
    const client = new Client({ name: 'c', version: '1' });
    const [ct, st] = InMemoryTransport.createLinkedPair();
    await Promise.all([client.connect(ct), mcpServer.connect(st)]);

    await client.listTools();
    expect(client.isToolCacheStale).toBe(false);   // fresh after fetch
    vi.advanceTimersByTime(500);
    expect(client.isToolCacheStale).toBe(false);   // clamp: 0 → 1s, not yet stale
    vi.advanceTimersByTime(600);
    expect(client.isToolCacheStale).toBe(true);    // ttl elapsed

    await client.listTools();
    expect(client.isToolCacheStale).toBe(false);   // reset on re-fetch
    vi.useRealTimers();
});

This covers the getter, the clamp, and the reset in one place and makes the PR description accurate.

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +90 to +96
/**
* Optional `ttl` (in seconds) included on `tools/list`, `prompts/list`, `resources/list`,
* and `resources/templates/list` responses (SEP-2549). Tells clients how long the response
* may be considered fresh; clients may cache and re-poll on this schedule. Supplements, does
* not replace, the `list_changed` notification mechanism.
*/
listTtlSeconds?: number;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This PR adds two new public API knobs (ServerOptions.listTtlSeconds and Client.isToolCacheStale) but does not add prose documentation for them under docs/ — the PR checklist's "I have added or updated documentation as needed" tick is currently inaccurate. A short section in docs/server.md (alongside the existing ## Server instructions section) and a mention of the staleness getter in docs/client.md would close the gap.

Extended reasoning...

What's missing

REVIEW.md's Tests & docs checklist says: "New feature: verify prose documentation is added (not just JSDoc), and assess whether examples/ needs a new or updated example." This PR introduces two user-facing API surfaces:

  • ServerOptions.listTtlSeconds (server.ts:90-96) — a new constructor option that causes McpServer to attach ttl to all four list responses.
  • Client.isToolCacheStale (client.ts) — a new public getter that tells callers when the SEP-2549 ttl has elapsed and the tool list should be re-fetched.

Both have JSDoc, but neither appears in the prose docs.

Step-by-step proof

  1. docs/ exists and contains server.md, client.md, server-quickstart.md, migration.md, etc.
  2. rg 'listTtlSeconds|isToolCacheStale|SEP-2549' docs/zero hits.
  3. rg 'listTtlSeconds|isToolCacheStale|SEP-2549' examples/zero hits.
  4. The full-repo grep matches only the six source/test files in this diff.
  5. Sibling ServerOptions fields do have prose docs, establishing the convention: docs/server.md has a dedicated ## Server instructions section with example code, and jsonSchemaValidator / enforceStrictCapabilities appear in docs/server.md, docs/client.md, and docs/migration.md. So listTtlSeconds is the first ServerOptions field with no prose coverage at all.
  6. The PR's own checklist ticks "[x] I have added or updated documentation as needed", while "[ ] Documentation update" under Types of changes is unticked — only JSDoc was added.

Why existing artifacts don't cover it

JSDoc surfaces in IntelliSense and the generated API reference, but the repo's review checklist explicitly distinguishes prose documentation from JSDoc, and the docs/*.md guides are where users learn that a feature exists in the first place. A user reading docs/server.md to discover "what options can I pass to McpServer?" will not see listTtlSeconds; a user reading docs/client.md to learn the caching behavior of listTools() will not learn that isToolCacheStale exists or that staleness is a re-fetch hint (validators stay enforced) rather than a cache-invalidation signal — a nuance worth spelling out in prose.

Impact

Documentation-completeness only — no runtime correctness impact, hence nit. The convention is also not perfectly uniform (not every ServerOptions field has its own ## heading), so this is a should-fix rather than a blocker. It is distinct from the three already-posted inline comments on this PR (spec.types.ts hand-edit, ServerOptions JSDoc placement, and missing client-side test coverage); none of those mention docs/.

Suggested fix

  • docs/server.md: add a short ## List result TTL (SEP-2549) section near ## Server instructions, showing new McpServer(info, { listTtlSeconds: 60 }) and noting that the value is rounded to a non-negative integer and supplements (does not replace) list_changed notifications.
  • docs/client.md: add a paragraph under the listTools() / caching discussion explaining client.isToolCacheStale, that it flips true once the server-supplied ttl elapses, and that validators remain enforced while stale (it's a re-fetch hint, not a drop-enforcement signal).
  • Optionally untick the docs checkbox in the PR description until the above lands.

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

Comment on lines +616 to +622
nextCursor: CursorSchema.optional(),
/**
* How long (in seconds) this result may be considered fresh before re-fetching (SEP-2549).
* Allows clients to cache list responses and poll on a predictable schedule, supplementing
* (not replacing) the `list_changed` notification mechanism.
*/
ttl: z.number().int().nonnegative().optional()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 This PR adds public API to three published packages — @modelcontextprotocol/core (PaginatedResult.ttl), @modelcontextprotocol/server (ServerOptions.listTtlSeconds), and @modelcontextprotocol/client (Client.isToolCacheStale) — but ships no .changeset/*.md entry (changeset-bot confirms "No Changeset found" at b8536fd). Without one, changesets/action will not bump versions or write CHANGELOG entries for any of these packages, so SEP-2549 ships silently; add a minor-bump changeset for core/client/server (the bot comment has the one-click link).

Extended reasoning...

What's missing

This PR adds new public API surface to three separately-published packages but contains no changeset file:

  • @modelcontextprotocol/corePaginatedResultSchema gains ttl: z.number().int().nonnegative().optional() (schemas.ts:616-622), which propagates ttl?: number onto the inferred PaginatedResult, ListToolsResult, ListResourcesResult, ListResourceTemplatesResult, ListPromptsResult, and ListTasksResult types.
  • @modelcontextprotocol/serverServerOptions gains listTtlSeconds?: number (server.ts:90-96) and McpServer reads it to populate every list response.
  • @modelcontextprotocol/clientClient gains a public get isToolCacheStale(): boolean getter and cacheToolMetadata grows a ttlSeconds? parameter.

The changeset-bot comment on the PR explicitly reports "⚠️ No Changeset found — Latest commit: b8536fd", and the six changed files in the diff include nothing under .changeset/.

Why the repo expects one

.changeset/ already contains dozens of entries for directly comparable changes — e.g. add-resource-size-field.md (a single optional schema field, just like ttl) and respect-capability-negotiation.md — and .github/workflows/release.yml uses changesets/action to drive version bumps and CHANGELOG generation. Grepping .changeset/ for SEP-2549|listTtl|isToolCacheStale returns zero hits; the only ttl match is busy-rice-smoke.md, which is the unrelated "tasks - disallow requesting a null TTL" entry. So this PR is the first feature-level addition in the directory's history without a corresponding changeset.

Step-by-step proof

  1. PR diff touches packages/{core,server,client}/src/** and test/** — no .changeset/*.md is added.
  2. changeset-bot pinned comment at HEAD b8536fd: "Merging this PR will not cause a version bump for any packages."
  3. .github/workflows/release.yml runs changesets/action; with no pending changeset for these packages, the next release PR will not include a version bump or CHANGELOG entry for core/client/server.
  4. Consumers therefore receive the new ttl wire field, the new listTtlSeconds option, and the new isToolCacheStale getter under an unchanged version number with no release-notes mention — exactly the silent-API-addition the changesets workflow exists to prevent.

Why nothing else covers it

This is distinct from the four inline comments already on the PR (spec.types.ts hand-edit — now resolved; ServerOptions JSDoc placement; missing client-side test coverage; missing docs/ prose). None of those mention changesets or the release workflow. The bot comment surfaces the gap on the PR page, but it does not block merge, and three @claude review rounds have gone by without it being addressed.

Impact

Process / release-hygiene only — there is no runtime defect, hence nit. The practical consequence is that SEP-2549 ships without a semver bump or CHANGELOG line, which (a) makes the feature undiscoverable in release notes and (b) means downstream lockfiles won't pick it up as a new version. Easy to fix and routinely caught at merge time, but worth flagging since the PR claims "non-breaking change which adds functionality", which is precisely the changeset minor use-case.

Suggested fix

Add a single changeset (the bot's "Click here if you're a maintainer who wants to add a changeset" link pre-fills it), e.g.:

---
"@modelcontextprotocol/core": minor
"@modelcontextprotocol/server": minor
"@modelcontextprotocol/client": minor
---

feat: SEP-2549 list-result `ttl``PaginatedResult.ttl`, `ServerOptions.listTtlSeconds`, `Client.isToolCacheStale`

(@modelcontextprotocol/test-integration is private/test-only and does not need an entry; the bot's auto-suggested patch for it can be dropped.)

@felixweinberger
Copy link
Copy Markdown
Contributor Author

@claude review

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

Labels

v2-stateless 2026-06 SDK: Protocol decomposition + SEP alignment (request-first / stateless)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant