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-1083 — CacheableResult, 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:
- 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.
- Add macro attributes, e.g.
#[tool_handler(router = self.tool_router, ttl_ms = 60_000, cache_scope = "public")], with spec-valid defaults when omitted.
- 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. 🦉
Affects:
rmcp/rmcp-macros3.1.0 (current stable), and every 3.x release since SEP-2549 landedSummary
A server that uses
#[tool_handler]without hand-writinglist_toolsproduces atools/listresult with nottlMsand nocacheScope. Both are required at2026-07-28, so a spec-strict client rejects the response outright — before it ever reaches a tool call.#[prompt_handler]has the identical defect forprompts/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-28client 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.rsandcrates/rmcp-macros/src/prompt_handler.rsare both in that PR's diff, andNonewas 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-78rmcp-macros-3.1.0/src/prompt_handler.rs:63-70is the same shape forListPromptsResult.Both
Options serialize as omitted, so the fields are simply absent on the wire.What the spec requires
schema/draft/schema.ts:1054-1083—CacheableResult, whichListToolsResultandListPromptsResultextend:And the 2026-07-28 changelog, minor change 5:
Reproduction
Any server whose
ServerHandlerimpl is generated by#[tool_handler]and does not definelist_toolsitself. Captured from a raw stdio JSON-RPC client (no SDK on the client side) against such a server onrmcp 3.1.0:{ "jsonrpc": "2.0", "id": "list-1", "result": { "resultType": "complete", "tools": [ ... ] } }No
ttlMs, nocacheScope.Connecting the TypeScript SDK 2.0.0 client to the same server:
The connection itself succeeds —
server/discoverreturns fine — so the failure surfaces at the firsttools/list, which makes it look like a client bug until you read the wire.Why conformance is green
conformance/src/bin/server.rsuses neither#[tool_handler]nor#[tool_router]. It hand-writesimpl ServerHandler for ConformanceServerwith its ownasync fn list_tools(:780) and sets the fields explicitly:— 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_toolsin the impl; the macro skips generation when the method is already present: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:
ttl_ms: Some(0)andcache_scope: Some(CacheScope::Public)would conform, andttlMs: 0means "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.#[tool_handler(router = self.tool_router, ttl_ms = 60_000, cache_scope = "public")], with spec-valid defaults when omitted.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
#[tool]dropsrequest_state/input_responses, so MRTR handlers cannot use it #1095 —#[tool]dropsrequest_state/input_responses; same class of defect, where the macro omits something the spec needsEnvironment
rmcp/rmcp-macros3.1.0 from crates.io@modelcontextprotocol/client2.0.02026-07-28Found while porting the
quickstart-resourcesRust example to2026-07-28— the hand-writtenlist_toolsshown 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
Nonedefaults in #889, I'd rather be corrected than have you spend time on a non-issue. 🦉