Repository navigation
Rationalize feature flags for v3.0 #1067
Description
Labels: T-enhancement, T-config, T-documentation
Problem
rmcp exposes 26 feature flags (including implicit ones). They have grown organically — roughly one flag per new optional dependency — rather than from a user-facing design. Concretely:
- Names mix four unrelated concerns. Role (
client,server), protocol capability (elicitation,request-state,auth), transport (transport-io,transport-streamable-http-*), and raw dependency crate names (base64,uuid,schemars,tower,reqwest) all sit in one flat list with no way to tell them apart. - Internal building blocks look like public flags.
client-side-sse,server-side-http,transport-streamable-http-server-session,base64,uuidall appear on docs.rs but should never be enabled directly. Meanwhile__reqwestis correctly hidden — so the convention exists but is applied to exactly one flag. - Common use cases need 3+ flags, and one of them is a silent trap. A Streamable HTTP client needs
client+transport-streamable-http-client-reqwest+reqwest. Omit the last one and it still compiles — reqwest is pulled without any TLS backend, so everyhttps://request fails at runtime. Our ownconformance/Cargo.tomldoes exactly this; it survives only because it targetshttp://localhost. - Protocol capabilities are gated inconsistently.
elicitationis opt-in (only to gate theurlcrate), while sampling, tasks (SEP-2663), and response caching (SEP-2549) are always on. There is no principle a user can infer. localviolates Cargo's additivity contract. It removesSendbounds crate-wide for WASM/single-threaded use. Because Cargo features are additive, any crate anywhere in the dependency graph enablinglocalsilently changes trait bounds for everyone. The name gives no hint of this, and it is undocumented.
Use cases today
What a user must put in Cargo.toml right now, per scenario:
| Use case | Required features today | Pain |
|---|---|---|
| Stdio server (most common server) | default (server, macros, base64) + transport-io |
transport-io doesn't say "stdio"; spec's term is stdio transport |
| Stdio client (spawns a server) | client, transport-child-process, default-features = false |
default drags in server + schemars + pastey the client never uses |
| Streamable HTTP server | server, macros, transport-streamable-http-server |
OK; tower integration arrives via an internal flag |
| Streamable HTTP client | client, transport-streamable-http-client-reqwest, reqwest |
Trap: omit reqwest → compiles, but all TLS fails at runtime |
| HTTP client, non-default TLS | above but reqwest-native-tls / reqwest-tls-no-provider |
reqwest actually means "reqwest + rustls"; segment order inconsistent (reqwest-native-tls vs reqwest-tls-no-provider) |
| HTTP client, custom HTTP stack | client, transport-streamable-http-client |
fine |
| HTTP client over Unix socket | client, transport-streamable-http-client-unix-socket |
six-segment name; undocumented in README |
| OAuth client | + auth (+ auth-client-credentials-jwt) |
auth pulls reqwest via __reqwest — same silent no-TLS trap |
| Server using elicitation | + elicitation |
the only protocol capability behind a flag; exists only to gate url |
| Sealed request state (SEP-2322) | + request-state |
fine (real crypto deps), but undocumented in README |
| WASM / single-threaded | local (+ transport-worker) |
non-additive; opaque name; undocumented |
Dead remnant: transport-ws exists only as a comment in Cargo.toml, yet src/transport/ws.rs and a commented #[cfg] in transport.rs remain.
Proposal
A naming taxonomy — one rule per axis
| Axis | Convention | Examples |
|---|---|---|
| Role | bare word | client, server |
| Protocol capability | spec terminology, kebab-case | request-state, auth |
| Transport | transport- prefix, spec terminology |
transport-stdio, transport-streamable-http-server |
| Ecosystem integration | crate name (established Rust convention) | tower |
| TLS backend | tls- prefix |
tls-rustls, tls-native, tls-no-provider |
| Use-case bundle | <role>-<transport> |
client-http, server-stdio |
| Internal building block | __ prefix, hidden from docs |
__server-side-http |
1. Renames (v3.0, with one-release aliases)
| Today | Proposed | Rationale |
|---|---|---|
transport-io |
transport-stdio |
match spec vocabulary; users search "stdio" |
reqwest |
tls-rustls |
the flag's real meaning is the TLS choice, not the HTTP crate |
reqwest-native-tls |
tls-native |
consistent tls- prefix + order |
reqwest-tls-no-provider |
tls-no-provider |
same |
local |
unsync (or replace with cfg(target_family = "wasm")) |
name should say what it does; ideally not a feature at all, since it is non-additive |
which-command |
fold into transport-child-process (or command-path-lookup) |
named after the which crate, not the behavior |
Old names stay as empty forwarding aliases (transport-io = ["transport-stdio"]) for one major cycle, then are removed.
2. Un-gate capabilities that only exist to hide a trivial dep (v3.0)
| Today | Proposed | Rationale |
|---|---|---|
elicitation |
always on | gates a core capability while nothing else (sampling/tasks/caching) is gated. Correction: url is not a trivial dep — making it mandatory adds 29 crates to a minimal build via idna → ICU4X (37 → 66). Un-gating therefore also requires the elicitation API to take URLs as plain strings, so url stays behind auth. New rule: gate a capability only when it carries a heavy dep — request-state (hmac/sha2) and auth (oauth2) stay opt-in |
base64 |
always on (already in defaults) | implicit dep-named flag gating three image-content helpers |
uuid |
hidden via dep:uuid |
implicit dep-named flag; pure implementation detail |
3. Hide internal building blocks (v3.0)
client-side-sse, server-side-http, transport-streamable-http-server-session get the __ prefix (matching __reqwest) and leave the docs.rs feature list. Users compose transports; they don't assemble the parts.
transport-worker was originally listed here too, but it stays public: crates/rmcp/README.md and the transport module docs both present implementing Worker as one of the ways to build a transport, so hiding the flag would make a documented extension point unreachable.
4. Use-case meta-features (non-breaking, can ship now)
server-stdio = ["server", "macros", "transport-stdio"]
server-http = ["server", "macros", "transport-streamable-http-server"]
client-stdio = ["client", "transport-child-process"]
client-http = ["client", "transport-streamable-http-client-reqwest", "tls-rustls"]Getting-started docs become one flag per scenario. client-http bakes in rustls; the raw no-TLS combination stays legal (plain-http:// deployments need it) but becomes an opt-in, not a pitfall.
5. Housekeeping (non-breaking, can ship now)
- README feature table documents ~13 of 26 flags; document all public ones (missing:
request-state,auth-client-credentials-jwt,which-command,tower,local, unix-socket transport). - Delete
src/transport/ws.rsand the commentedtransport-wsremnants (recoverable from git if WebSocket ever lands). - Document the TLS pitfall until the meta-features land.
Use cases after this proposal
| Use case | Features after |
|---|---|
| Stdio server | server-stdio (or defaults + transport-stdio) |
| Stdio client | client-stdio, default-features = false |
| Streamable HTTP server | server-http |
| Streamable HTTP client | client-http |
| HTTP client, native TLS | client, transport-streamable-http-client-reqwest, tls-native |
| HTTP client, custom stack | client, transport-streamable-http-client |
| OAuth client | client-http, auth |
| Server using elicitation | nothing extra — always available |
| Sealed request state | + request-state |
| WASM / single-threaded | unsync (or automatic via target cfg) |
Rollout
Ship now (minor, non-breaking):
- Use-case meta-features (§4)
- README coverage + TLS-pitfall docs (§5)
- Delete
ws.rs/transport-wsremnants (§5)
v3.0 (breaking):
- Renames with forwarding aliases (§1)
- Un-gate
elicitation/base64, hideuuid(§2) -
__-prefix internal flags (§3)
v4.0:
- Drop the forwarding aliases
Open questions
local→ renamed feature vs.cfg(target_family = "wasm")? Does anyone uselocalon native targets (e.g.LocalSet-based single-threaded servers)? If yes, a renamed feature stays; if wasm-only, target cfg is strictly better — it removes the additivity violation entirely.- Should defaults shrink? Shrinking away from
server/macrosis a bigger break for the majority (server authors). This proposal leaves defaults unchanged. tls-*vs keepingreqwest-tls-*? If a second HTTP backend ever lands,tls-*generalizes better;reqwest-tls-*is more honest today. Proposal preferstls-*.
Activity
DaleSeo commentedon Jul 28, 2026
Implementation is up on
feat/rationalize-feature-flags; PR to follow. §1–§5 are all in. Two things came out differently from the proposal above, and I have edited the body to match.1.
transport-workerstays public§3 listed it among the internals to hide. It should not be:
crates/rmcp/README.mdand thetransportmodule docs both present implementingWorkeras one of the ways to build a transport, so hiding the flag would make a documented extension point unreachable. The other three internals are__-prefixed as proposed.2. "
urlis a trivial dep" was wrongUn-gating
elicitationmakesurlmandatory, andurlpullsidna→ ICU4X. Measured againstmainwith an identicalCargo.lock:urlmandatory--no-default-featuresThe fix keeps §2's promise — elicitation needs no flag — without the weight.
elicit_urlandelicit_url_with_timeoutnow takeimpl Into<String>rather thanimpl Into<Url>; the wire model already stored aStringand the value was stringified immediately, sourlcan stay behindauth. Callers passing aUrlstill compile unchanged viaimpl From<Url> for String, and&str/Stringare newly accepted. A minimal build now adds onlybase64, a leaf crate with no transitive deps.Worth reviewing on its own terms: this is an API change rather than a feature-flag change, and rmcp no longer validates the URL.
Two guards added
Neither
--all-featuresnor the aggregate feature list catches these, so both are new:feature-matrixCI job that builds every public feature on its own. This catches a missingdep:entry that a sibling feature would otherwise supply — invisible when everything is enabled at once. All 33 public features currently build standalone.tests/test_feature_aliases.rs, static assertions that each deprecated alias still forwards. Building everything at once cannot catchtransport-io = [], becausetransport-stdiois enabled independently anyway. Verified by injecting exactly that typo: the build fails withevaluation panicked: `transport-io` must forward to `transport-stdio`.A coordination note for the review window
While the branch was open, #1068 merged code naming the pre-rename
localflag: theClientHandlerbounds incrates/rmcp/src/handler/client.rsand atest_local_client_handlertarget. Rebasing produced no conflict there — git merges text, not renames — and the result compiles either way, becauselocalsurvives as an alias andSend + Syncis merely the stricter bound. It would have surfaced only in downstream code that enables the canonicalunsyncand implements a!Sendhandler.So while this is in review: new feature gates should use the v3 names, and rebasing this branch needs a grep for the old ones rather than trusting a clean merge.
This does not close the issue
Two items remain:
unsyncas a feature vscfg(target_family = "wasm")On the second — the rename made the name honest but left the mechanism intact. Because Cargo unifies features across the dependency graph, any crate anywhere enabling
unsyncdrops theSendbounds for every other rmcp user in that build, and also removesStreamableHttpServicefrom the public API. Neither is something an affected crate can opt out of. A target cfg would eliminate the violation outright, but only if nobody relies onunsyncfor single-threaded native targets, which is a call about real usage rather than about the code.Open questions 2 and 3 are implemented as proposed: defaults unchanged,
tls-*naming adopted.github-actions commentedon Sep 29, 2026
This issue has had no activity for 60 days and is now marked as stale. It will be closed in 14 days if there is no further activity. Add the
keeplabel to keep it open.