Skip to content

#[tool_handler] and #[prompt_handler] emit ttl_ms: None / cache_scope: None, which a 2026-07-28 client rejects #1114

Description

@olaservo

Affects: rmcp / rmcp-macros 3.1.0 (current stable), and every 3.x release since SEP-2549 landed


Summary

A server that uses #[tool_handler] without hand-writing list_tools produces a tools/list result with no ttlMs and no cacheScope. Both are required at 2026-07-28, so a spec-strict client rejects the response outright — before it ever reaches a tool call.

#[prompt_handler] has the identical defect for prompts/list.

Since the macro is the documented, idiomatic path, the default way to write an rmcp server currently produces a server that a strict 2026-07-28 client cannot talk to.

Why this isn't simply an oversight

Worth stating up front, because it changes what the fix should be: #889 did update both macro files. crates/rmcp-macros/src/tool_handler.rs and crates/rmcp-macros/src/prompt_handler.rs are both in that PR's diff, and None was the value chosen for the new fields. So this is a question about what the generated default should be, not a missed file.

The generated code

rmcp-macros-3.1.0/src/tool_handler.rs:71-78

Ok(rmcp::model::ListToolsResult{
    result_type: Some(rmcp::model::ResultType::COMPLETE),
    tools: #router.list_all(),
    meta: #result_meta,
    next_cursor: None,
    ttl_ms: None,          // <-- required at 2026-07-28
    cache_scope: None,     // <-- required at 2026-07-28
})

rmcp-macros-3.1.0/src/prompt_handler.rs:63-70 is the same shape for ListPromptsResult.

Both Options serialize as omitted, so the fields are simply absent on the wire.

What the spec requires

schema/draft/schema.ts:1054-1083CacheableResult, which ListToolsResult and ListPromptsResult extend:

export interface CacheableResult extends Result {
  ttlMs: number;                        // not optional
  cacheScope: "public" | "private";     // not optional
}

And the 2026-07-28 changelog, minor change 5:

Require ttlMs and cacheScope fields on results returned by tools/list, prompts/list, resources/list, resources/read, and resources/templates/list via a new CacheableResult interface.

Reproduction

Any server whose ServerHandler impl is generated by #[tool_handler] and does not define list_tools itself. Captured from a raw stdio JSON-RPC client (no SDK on the client side) against such a server on rmcp 3.1.0:

{
  "jsonrpc": "2.0",
  "id": "list-1",
  "result": {
    "resultType": "complete",
    "tools": [ ... ]
  }
}

No ttlMs, no cacheScope.

Connecting the TypeScript SDK 2.0.0 client to the same server:

SdkError: Invalid result for tools/list: [
  {
    "expected": "number",
    "code": "invalid_type",
    "path": ["ttlMs"],
    "message": "Invalid input: expected number, received undefined"
  },
  {
    "code": "invalid_value",
    "values": ["public", "private"],
    "path": ["cacheScope"],
    "message": "Invalid option: expected one of \"public\"|\"private\""
  }
]

The connection itself succeeds — server/discover returns fine — so the failure surfaces at the first tools/list, which makes it look like a client bug until you read the wire.

Why conformance is green

conformance/src/bin/server.rs uses neither #[tool_handler] nor #[tool_router]. It hand-writes impl ServerHandler for ConformanceServer with its own async fn list_tools (:780) and sets the fields explicitly:

const CACHE_TTL_MS: u64 = 60_000;   // :28
...
.with_ttl_ms(CACHE_TTL_MS)
.with_cache_scope(CacheScope::Public)

— in six places across the file.

So the suite passes 40/40 while never exercising the macro-generated path. This is a coverage gap rather than a conformance regression, and it isn't in the known-gaps list at ROADMAP.md#spec-features-without-conformance-scenarios (which currently names only SEP-2567, SEP-2260, and the SEP-2549 client-cache follow-up).

Workaround

Define list_tools in the impl; the macro skips generation when the method is already present:

#[tool_handler(router = self.tool_router)]
impl ServerHandler for Weather {
    async fn list_tools(
        &self,
        _request: Option<PaginatedRequestParams>,
        _context: rmcp::service::RequestContext<rmcp::RoleServer>,
    ) -> Result<ListToolsResult, ErrorData> {
        Ok(ListToolsResult::with_all_items(self.tool_router.list_all())
            .with_ttl_ms(60_000)
            .with_cache_scope(CacheScope::Public))
    }
}

That works, but it gives up most of what the macro is for.

Possible directions

Deliberately not prescribing one — the right default is a judgement call for the maintainers:

  1. Generate spec-valid defaults. ttl_ms: Some(0) and cache_scope: Some(CacheScope::Public) would conform, and ttlMs: 0 means "immediately stale", which is the safe reading for a server that hasn't opted into caching. Downside: it silently picks a caching policy on the user's behalf.
  2. Add macro attributes, e.g. #[tool_handler(router = self.tool_router, ttl_ms = 60_000, cache_scope = "public")], with spec-valid defaults when omitted.
  3. Take them from the server, the way the Ruby SDK does with MCP::Server.new(ttl_ms:, cache_scope:) — a server-level setting the generated handler reads.

Option 1 alone would fix the wire-level breakage; 2 or 3 would additionally make the hints usable without abandoning the macro.

Related

Environment

  • rmcp / rmcp-macros 3.1.0 from crates.io
  • Client: @modelcontextprotocol/client 2.0.0
  • Verified on Windows, stdio transport, protocol 2026-07-28

Found while porting the quickstart-resources Rust example to 2026-07-28 — the hand-written list_tools shown above as the workaround is what that example currently carries.

Investigated and drafted with Claude Code. Every claim here was checked against the crates.io sources and reproduced on the wire rather than inferred; if I've misread the intent behind the None defaults in #889, I'd rather be corrected than have you spend time on a non-issue. 🦉

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions