# xyz-rust — One definition, three interfaces
[](https://crates.io/crates/xyz-rust)
[](https://docs.rs/xyz-rust)
[](https://www.rust-lang.org/)
[](https://modelcontextprotocol.io/specification/2026-07-28)
[](#dependency-policy--binary-size)
[](README.zh-CN.md)
The Rust port of [xyz-go](https://github.com/ejfkdev/xyz-go): **define a command once** (argument struct + validation + per-interface details) and one binary automatically speaks three interfaces — **CLI subcommands**, an **HTTP REST service** (with an OpenAPI document), and an **MCP tool server** (official Rust SDK). The library decides the running mode by itself.
```rust
use xyz_rust::errs;
use xyz_rust::{define, CliHints, HTTPHints, MCPHints, XyzArgs};
#[derive(XyzArgs)]
struct AddArgs {
#[xyz(desc = "username", required, validate = "min=2,max=32", cli = "positional", http = "path")]
name: String,
#[xyz(desc = "age", default = "18")]
age: i32,
}
fn add(_ctx: &xyz_rust::Ctx, in_: &AddArgs) -> errs::Result<String> {
Ok(format!("{} is {}", in_.name, in_.age))
}
fn main() {
define("user.add", add)
.summary("Add a user")
.cli(CliHints { usage: "add <name>".into(), ..Default::default() })
.http(HTTPHints { method: "POST".into(), path: "/users/{name}".into(), ..Default::default() })
.mcp(MCPHints { annotations: vec!["write".into()], ..Default::default() })
.run(); // register + dispatch + exit — that's the whole program
}
```
```console
$ cargo run -p xyz-example -- user add bob -a 20 # CLI: subcommands + shorthand flags
id 1
name bob
age 20
token_set false
$ cargo run -p xyz-example -- user list # Vec<struct> renders as an aligned table
id name age token_set
-- ----- --- ---------
1 alice 18 false
2 bob 25 false
$ curl -s -X POST localhost:8080/users/alice -d '{"age":9}'
{"id":1,"name":"alice","age":9,"token_set":false}
$ cargo run -p xyz-example -- math sum --a 1 --b 2 # bare primitive, one line
3
$ cargo run -p xyz-example -- mcp stdio # MCP: every command becomes a tool (stdio/streamable HTTP)
```
## Features
- **One definition, zero boilerplate**: the whole `main` is a single `define(...) ... .run()` chain — no `std::process::exit`, no explicit registry, no dispatch switch.
- **One pipeline across all three interfaces**: CLI (strings), HTTP (JSON) and MCP arguments are normalized into the same `serde_json` map and flow through the same decode → defaults → validate → handler path, so behavior never drifts.
- **Per-interface fine-tuning**: shorthands, aliases, env fallbacks, binding locations — and **interface-specific default values** (two-tier layering: global attribute default → per-interface override).
- **Envelope-free responses**: primitives print bare, structs align as `key value` columns, `Vec<struct>` becomes a table, `--json` flips to JSON; HTTP answers bare JSON; MCP returns the human `textContent` plus `structuredContent` for object results (MCP specifies an object there — §12.5).
- **One error taxonomy — with rich context**: a single `errs::new(errs::Kind::NotFound, ...)` drives the CLI exit code, the HTTP status code and the MCP error code simultaneously; `with_code/with_detail/with_status` attach the §8.5 layers and every channel shares one error body (`{error, kind, code, detail}`, spec §8.6).
- **TTY-aware output**: `--format auto` (the default) renders `text` on an interactive terminal and `jsonl` when piped; `--format text|json|jsonl|markdown` and the `--json` alias pick explicitly, with the precedence bare flag > `--xyz.format` > `CliHints.format` > `Config.Format` > auto (spec §10.7).
- **Four modes, namespaced**: `serve` (REST + `/mcp`), `http` (REST only), `mcp`, `help` — each also reachable as the always-available, help-hidden `xyz.<word>`; user commands whose top-level segment equals a mode word shadow the bare form (spec §13.1).
- **Dependency hygiene**: the core modules (spec / registry / errors / cli / logx / root) have zero third-party dependencies apart from the serde family and chrono; the only other third-party tree is the official Rust SDK (rmcp), removable wholesale with `--no-default-features` (smallest trimmed build ≈ 0.95M).
- **Protocol versions under control**: MCP speaks the five spec revisions from 2024-11-05 to 2026-07-28; `--versions` pins the subset. Tools carry a macro-generated `outputSchema` for object-typed results — and only for negotiated revisions 2025-06-18 and later (OpenAPI response schemas share the same source).
- **Production-friendly**: SIGINT/SIGTERM graceful shutdown (`Ctx` flows into handlers), `/healthz` probe, gzip, CLI help with inline `(default …)`/`(env …)`/`(oneof …)` hints, and `completion bash|zsh|fish`.
- **Built-ins out of the box**: credentials (`--bearer`), default address, log level, timeout, TLS, CORS live in `Config` and the `--xyz.*` command-line namespace.
## Install
```bash
cargo add xyz-rust # the crate name is xyz-rust — on crates.io `xyz` was already taken by an unrelated project
```
Import as `use xyz_rust::...`. Published on crates.io as `xyz-rust` (current 0.4.x line); the bundled examples run from this repository as path dependencies. Requires **Rust 1.89+** (MSRV: the official Rust SDK rmcp 3.6.0 brings `uuid` 1.28, whose floor is 1.89) and edition 2024. A complete runnable showcase lives in [examples/example](examples/example/src/main.rs) (crate `xyz-example`, 14 commands covering the full API surface); [examples/tour](examples/tour/src/main.rs) (`xyz-tour`) walks through the internals, and [examples/clap](examples/clap/src/main.rs) (`xyz-clap`) shows coexistence with an existing clap app. The compile-time derive macros live in the companion crate `xyz-rust-macros`, pulled in automatically by `xyz-rust`.
## One definition: the argument struct
Attributes on the argument struct form the **shared contract** across every interface (wire names, descriptions, defaults, required-ness, enums, validation, secrecy). They map one-to-one onto the Go port's tags:
| Go tag | Rust attribute | Meaning | Example |
|---|---|---|---|
| `json:"name"` | `#[serde(rename = "name")]` (or `#[xyz(name = "name")]`) | Wire field name; `#[xyz(skip)]` (≡ `json:"-"`) excludes the field from binding & schema (still injectable via env/header by Rust field name) | `#[serde(rename = "user_name")]` |
| `desc:"..."` | `#[xyz(desc = "...")]` | Field description (CLI help and JSON Schema alike) | `#[xyz(desc = "username")]` |
| `default:"..."` | `#[xyz(default = "...")]` | Global default, parsed per field type, overridable per interface | `#[xyz(default = "18")]` |
| `required:"true"` | `#[xyz(required)]` | Must be provided | `#[xyz(required)]` |
| `enum:"a,b"` | `#[xyz(enum = "a,b")]` | Allowed values (enforced at decode; written into schema) | `#[xyz(enum = "fast,slow")]` |
| `validate:"..."` | `#[xyz(validate = "...")]` | Validation rules (built-in validator; see the supported set below) | `#[xyz(validate = "min=2,email")]` |
| `secret:"true"` | `#[xyz(secret)]` | Sensitive: redact in help/logs/echoes | `#[xyz(secret)]` |
| `cli:"..."` | `#[xyz(cli = "...")]` | CLI bindings: `shorthand=a`, `positional`, `hidden`, `env=VAR`, `-` | `#[xyz(cli = "shorthand=a,env=TOKEN")]` |
| `http:"query"` | `#[xyz(http = "query")]` | HTTP binding: `query` (**the default when unset**) / `path` / `header` / `form` / `body` | `#[xyz(http = "header")]` |
| `httpName:"X-Key"` | `#[xyz(http_name = "X-Key")]` | HTTP wire-name override (typically a header name) | `#[xyz(http_name = "X-Api-Key")]` |
A minimal serde subset is also honored by the derive macro: `#[serde(rename = "...")]`, `#[serde(rename_all = "...")]` and `#[serde(skip)]`.
`validate` supports: `required`, `omitempty`, `min`, `max`, `len`, `gt`, `gte`, `lt`, `lte`, `oneof`, `email` — a go-playground-compatible subset. Unsupported rules fail at **registration time**, never silently at runtime.
Type support: `String`, `bool`, all integers (`i8`–`i64`, `u8`–`u64`), `f32`/`f64`, `Vec<T>` (Go `[]T`), `Vec<u8>` (Go `[]byte`), `Option<T>` (Go `*T`), nested structs, `chrono::DateTime<Utc>` (Go `time.Time`), `std::time::Duration` (Go `time.Duration`) and named scalar newtypes via `#[derive(XyzField)]`. All wired formats accept strings (CLI), JSON shapes (HTTP body) and raw JSON (MCP) with lossless conversion checks (`3.7` never silently becomes `int(3)`). There is no runtime reflection: what reflect does on the Go side is generated at compile time by the derive macros.
Named scalar newtypes, and nested argument structs:
```rust
#[derive(Clone, Copy, Debug, PartialEq, XyzField)]
struct Port(i32);
#[derive(XyzArgs)]
struct PortArgs {
#[xyz(desc = "listen port", default = "8080", cli = "shorthand=p")]
port: Port,
}
```
## Per-interface configuration & default layering
`.cli()/.http()/.mcp()` on the `define` chain configure command-level details and, via the `fields` map, override attributes per field (both layers merge; a zero-value hint field means "keep the attribute"). Fields are keyed by JSON name or Rust name:
```rust
.cli(CliHints {
usage: "add <name>".into(), // usage line in help
aliases: vec!["ua".into(), "new".into()], // aliases equal subcommand names
fields: HashMap::from([
("age".to_string(), CliFieldHint { shorthand: Some("a".into()), ..Default::default() }),
("mode".to_string(), CliFieldHint { default: Some("fast".into()), ..Default::default() }), // CLI-only default
("token".to_string(), CliFieldHint { env_var: Some("APP_TOKEN".into()), ..Default::default() }), // env fallback
]),
..Default::default()
})
```
Hint structs: `CliHints { usage, aliases, hidden, fields }` with `CliFieldHint { shorthand, positional, hidden, skip, env_var, default }`; `HTTPHints { method, path, timeout, fields }` with `HTTPFieldHint { location, name, default }`; `MCPHints { annotations, fields }` with `MCPFieldHint { default }`. A shorthand must be a single character (registration error otherwise).
**Default precedence for one field** (CLI as the example):
```
explicit flag > env fallback > interface default > global attribute default (Invoke fills it) > zero value
```
Mechanism: each frontend injects its own overrides (`Entry.cli_defaults()/http_defaults()/mcp_defaults()`) before calling `Entry.invoke`, which then applies global attribute defaults — one pipeline, drift-free. MCP's overrides also replace `default` in `inputSchema` (the schema is MCP's contract).
## Modes
```
xyz-example [command] [args] CLI: subcommand tree, shorthands/aliases/-h/-v/--format/positionals/env
xyz-example serve --addr :8080 HTTP: REST routes + /openapi.json + /mcp on the same port
xyz-example http --addr :8080 HTTP: REST routes + /openapi.json only (no /mcp)
xyz-example mcp stdio|http MCP: official Rust SDK, two transports (--versions pins revisions)
xyz-example help [mode|command] help: overview / a mode's help / detailed command help
xyz-example completion bash|zsh|fish Built-in shell completion scripts
```
`help` takes arguments (dotted or spaced command paths both work: `help user.add`
≡ `help user add`), and `serve -h` / `http -h` / `mcp -h` print the mode's own
help and exit without starting a server (spec §10.4/§13.2). Every mode word is
also reachable through its always-available, help-hidden `xyz.<word>` form; a
user command whose top-level segment equals a mode word *shadows* the bare
form — the bare word routes to the user command and the built-in stays
reachable via `xyz.<word>`, so names like `serve.*` are no longer reserved
(spec §13.1).
## Command channels, daemons & composable dispatch
- **Per-command channel switches**: `CliHints { skip }`, `HTTPHints { skip }`,
`MCPHints { skip }` remove the command from the marked channel only
(no subcommand node / no route / no tool); CLI `skip` also drops aliases
and completion words.
- **Daemon commands**: `CliHints { daemon: true }` declares a long-running
lifecycle — the handler blocks until the context is cancelled, the CLI
exits 0 gracefully, the return value is not rendered and the command is
implicitly CLI-only.
```rust
define("watch", watch).cli(CliHints { daemon: true, ..Default::default() });
fn watch(ctx: &Ctx, _: &Args) -> errs::Result<()> { while !ctx.cancelled() { sleep(Duration::from_millis(50)); } Ok(()) }
```
- **Channel defaults**: `--default k=v` (repeatable; comma-pairs) —
injected at serve/mcp startup, fills absent request/tool keys only;
precedence: explicit > env > interface default > **channel default** >
global default > zero. Any unrecognised `--key value` in serve/mcp is a
shorthand for it: `gs serve --index ./wiki` equals
`--default index=./wiki`. Code side: `Config.channel_defaults`.
- **Composable dispatch**: `let (code, handled) = xyz_rust::try_run(®, args)` —
full dispatch pipeline, but an unknown CLI top word returns `(0, false)`
silently so the host can route its own commands; `try_run_config` takes a
custom `Config`.
**Custom help blocks.** Free text blocks, raw multi-line, printed verbatim
(trailing newlines normalized to one); empty = no-op:
```rust
// Overview top/bottom (Config) — app name, description, version, repo…
.run_config(Config {
help_before: "udf v1.0.0 — inspect disk images\nhttps://github.com/example/udf".into(),
help_after: "More examples: https://github.com/example/udf#examples".into(),
..Default::default()
});
// Per-command -h (CliHints):
define("extract", extract)
.cli(CliHints { before: "extract — 解包镜像".into(), after: "仓库: https://…".into(), ..Default::default() })
```
## Interface language
The interface language of all built-in text (overview, help labels, usage
errors, diagnostics) is picked in this order: `--xyz.lang=en|zh-CN` flag >
`Config.lang` > `LANG`/`LC_ALL` environment (a lowercase `zh` prefix picks
Chinese) > **English (default)**. Both languages ship in the library.
Configure more multilingual content with `Config.translations`
(language → (message key → text)); key names and English wording are the
xyz-spec §15.5 canonical catalog:
```rust
.run_config(Config {
lang: "zh-CN".into(), // or: LANG=zh_CN ./app; or: --xyz.lang=zh-CN
translations: HashMap::from([(
"en".to_string(),
HashMap::from([("help.help_flag".to_string(), "show this help".to_string())]),
)]),
..Default::default()
})
```
Error taxonomy messages stay English; user content (summaries,
descriptions, help blocks) is never translated.
**Default subcommand** (CLI only): a command marked `default: true` in its
`CliHints` becomes the default child of its parent node — when the first
argument matches no registered segment (and is not a flag), the whole
argument list is forwarded to it unchanged:
```rust
define("extract", extract)
.cli(CliHints { usage: "extract <image.tar>", default: true, ..Default::default() })
.run();
// udf ./image.tar ⟺ udf extract ./image.tar
// one default per parent node; flags/explicit paths/-h/-v are unaffected
```
**CLI** (std + serde; no clap in the shipped frontend — `examples/clap` shows how to bring your own): registry name `user.add` becomes the two-level subcommand `user add`; `-h/--help` prints per-command help (with inline `(default …)`/`(env …)`/`(oneof …)` hints), -v/--version` prints the *application* version (default `dev`, overridable with `set_version("v1.2.3")` — Rust has no `-ldflags -X` equivalent; the xyz SDK's own version is reported separately as `X-XYZ-Version`/`_meta.xyz.sdk_version`, spec §11.6/§12.8).
Output formats: `--format auto|text|json|jsonl|markdown` (with `--json` as the
`json` alias) — `auto` is the default and resolves at render time from the TTY
(interactive → `text`, piped/redirected → `jsonl`; both halves configurable via
`Config.FormatInteractive`/`FormatPiped`, forceable for tests/embedding via
`cli::Options { interactive: Some(..) }`). Precedence: bare `--format`/`--json`
(when not shadowed by a command's own flag) > `--xyz.format` > `CliHints.format`
> `Config.Format` > `auto`. In the machine formats (`json`/`jsonl`) a command
error is written to stderr as the shared §8.6 error object.
**HTTP** (axum): a command with an `HTTPHints { path }` is routed — a `method` value pins one method or a comma-separated list (case-normalized), and **an empty method registers both GET and POST** (GET binds query, POST binds a JSON/form body plus query, same handler; spec §11.1); `{name}` is a path parameter bound to a field with `http = "path"`; fields without an `http:` attribute bind from the query string by default (the §4.6 `http_name` override applies to query and form lookups too), a JSON body merges as the argument base; **unknown query parameters are rejected** with 400 `invalid_input` naming the parameter and the route's accepted set (`--xyz.allow-unknown-query` relaxes this) — silently ignoring them would let a typo or a removed parameter look effective (spec §11.2); supported methods are GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS (others are a registration error); the error taxonomy maps to status codes (400/401/403/404/409/499/500/503) with the shared §8.6 error body (`{"error":"...","kind":"...","code":"...","detail":{...}}` — flat `error` stays byte-compatible; an explicit `with_status` overrides the Kind-derived code; unmatched routes answer a localized JSON 404); per-request interface language comes from `Accept-Language` (q-ordered, `zh*`→zh-CN/`en*`→en, readable by handlers via `language_from_ctx`, spec §11.7); every response carries server-context headers (`X-App-Name`, `X-App-Version`, `X-XYZ-Version`; per-route `X-XYZ-Command`/`X-XYZ-Duration-Ms`) plus user-defined static headers (`--xyz.header k=v`; suppress the automatic set with `--xyz.no-server-headers`); `GET /openapi.json` serves a rich OpenAPI 3 document from the same `InputSchema`: per operation a summary + description, one `parameters` entry per path/query/header field (wire name, location, correct `required` — path params always —, `desc`, and the rich per-field schema with enum/default/format, the same source the MCP `inputSchema` uses; fields with no explicit `http` location appear as `query` parameters), a requestBody for POST/PUT/PATCH, one operation per registered method (the GET+POST default yields both), and `info.title`/`version` from the application identity (§11.6 values); `GET /healthz` probes liveness and gzip is answered transparently. Commands without HTTP hints are not routed.
**MCP** (official Rust SDK, rmcp): commands become tools; `tools/list` serves the macro-generated `inputSchema` and — for object-typed results, and only when the negotiated revision is 2025-06-18 or later — an `outputSchema` (a non-object result advertises none: a declared schema owes a conforming `structuredContent`, which an array or scalar can never provide); success returns the CLI-style `textContent` plus `structuredContent` when the result value is a JSON object; failures return `isError: true` with the classified message. `initialize` declares the tools capability (`"capabilities":{"tools":{}}`; `listChanged` is not claimed — the tool set is fixed and no change notifications are emitted), so clients that negotiate by capability send `tools/list`. Supported spec revisions: `2024-11-05`, `2025-03-26`, `2025-06-18`, `2025-11-25`, `2026-07-28` (the newest is the handshake-free `server/discover` era); `mcp http --versions 2025-06-18,2026-07-28` pins the subset (the pin also bounds the negotiated handshake version, and below 2025-06-18 it drops `outputSchema`/`structuredContent` entirely — the §12.5 revision gate); `--json-response` only takes effect together with `--stateless` (rmcp's legacy session mode always answers `text/event-stream`, and the server logs a warning when the combination is missing). Tool metadata is rich and per-command overridable (spec §12.4): the description merge is the default (`MCPHints.description` overrides), `MCPHints.title` (or a `title:…` annotation) rides `annotations.title`, `MCPHints.meta` merges into the tool's `_meta`, and a field's description can be overridden with `MCPFieldHint.description`. Every result carries `_meta.xyz` server context — `app_name`/`app_version`/`sdk_version`/`command`/`duration_ms` (+ `headers` when configured), and on failures an `error` sub-object with `kind`/`code`/`detail` — while `serverInfo` reports the *application* identity (basename / built version unless `Config.name`/`Config.version` override; spec §12.6/§12.8). Built-in constraints: streamable HTTP serves 2026-07-28 only with `--stateless` (SEP-2567); the SDK's streamable-HTTP server allows only loopback `Host` headers by default (its DNS-rebinding protection). `mcp sse` answers a clear error and exits 2 — see [the differences section](#differences-from-the-go-implementation).
A larger real command — attributes, three-layer defaults, error classification, named scalar, `Vec<u8>` and header/env injection all in one definition (taken from [examples/example/src/main.rs](examples/example/src/main.rs)):
```rust
#[derive(XyzArgs)]
struct AddUserArgs {
#[xyz(desc = "用户名称", required, validate = "min=2,max=32", cli = "positional", http = "path")]
name: String,
#[xyz(desc = "邮箱", validate = "omitempty,email")]
email: String,
#[xyz(desc = "年龄", default = "18", http = "query")]
age: i32,
#[xyz(desc = "部署模式", enum = "fast,slow", http = "query")]
mode: String,
#[xyz(desc = "标签", http = "query")]
tags: Vec<String>,
#[xyz(skip, secret, desc = "API 令牌(仅 env 注入,不进 schema)")]
token: String,
#[xyz(desc = "打印详细信息")]
verbose: bool,
}
#[derive(Serialize, XyzOutput)]
struct AddUserResp {
#[serde(rename = "id")]
id: i64,
#[serde(rename = "name")]
name: String,
#[serde(rename = "age")]
age: i32,
#[serde(rename = "token_set")]
token_set: bool,
}
fn add_user(_ctx: &xyz_rust::Ctx, in_: &AddUserArgs) -> errs::Result<AddUserResp> {
if in_.name == "missing" {
return Err(errs::new(errs::Kind::NotFound, "no such user"));
}
// ...
}
```
Handlers have the shape `fn(&Ctx, &Args) -> Result<Resp, E>` where `E` is any `std::error::Error` (an existing classification is preserved along the error chain; anything unclassified falls back to `internal`) and `Resp` is `Serialize`. `define("name", handler)` fully infers both types — no explicit generic arguments needed.
### Response rendering (envelope-free)
| Return type | CLI | `--json` / HTTP body |
|---|---|---|
| `None` / null (or an empty `Vec<T>`) | nothing | JSON `null` (empty array → `[]`) |
| `string` / `bool` / numbers | bare value, one line | bare JSON value |
| `DateTime<Utc>` | RFC3339 | RFC3339 string |
| `Vec<scalars>` | one per line | JSON array |
| `struct` | aligned `key value` columns | JSON object |
| `Vec<struct>` | aligned table (header + rule) | JSON array |
MCP: the result travels as `textContent` (the CLI-rendered form above); `structuredContent` additionally carries the JSON object for `struct` results — and only for negotiated revisions ≥ 2025-06-18 (§12.5, conformance A.64). Array and scalar values are never placed there.
| `map` | key/value pairs in (insertion) order | JSON object |
| `xyz_rust::Blocks` (spec §12.7) | text blocks inline; binary blocks written to temp files, paths printed | block envelope JSON (`{"content":[…]}`); MCP carries the blocks verbatim as `Content` |
Structs and maps render through the same `serde_json::Value` path (declaration order preserved via `preserve_order`) — see the [differences section](#differences-from-the-go-implementation).
### Tagged unions (`oneOf`, spec §4.7)
Enum argument fields with `#[serde(tag = "…")]` adjacent tagging become
`oneOf` inputSchema branches — one per variant, discriminator pinned with a
`const`:
```rust
#[derive(xyz_rust::XyzArgs, serde::Serialize, serde::Deserialize)]
#[serde(tag = "type")]
enum Target {
Element { state_id: i64, index: i64 },
Coordinate { x: i64, y: i64 },
}
```
Decoding selects exactly one branch (zero or multiple matches are
`invalid_input`). Untagged or internally-tagged enums are rejected at
compile time. The CLI frontend rejects union fields at registration for
now (same stance as nested structs).
## Error taxonomy
Handlers return `errs::Result<T>` (`xyz_rust::errs`) and one classification drives the three interfaces:
| Kind | HTTP | CLI exit | JSON-RPC / MCP |
|---|---|---|---|
| `invalid_input` (auto for decode/validation) | 400 | 2 | -32602 |
| `unauthorized` | 401 | 1 | -32010 |
| `forbidden` | 403 | 1 | -32011 |
| `not_found` | 404 | 1 | -32001 |
| `conflict` | 409 | 1 | -32009 |
| `canceled` | 499 | 1 | -32012 |
| `unavailable` | 503 | 1 | -32603 |
| unclassified (falls back to `internal`) | 500 | 1 | -32603 |
```rust
return Err(errs::new(errs::Kind::NotFound, format!("user {:?} not found", in_.name)));
// or: errs::errorf!(errs::Kind::NotFound, "user {} not found", in_.name)
```
## Configuration: mode words & capability switches
All dispatch configuration lives in `xyz_rust::Config`; the zero value (derived `Default`) means defaults. Chain style uses `.configure(cfg)`, functional style uses `main_config` / `run_config`:
```rust
xyz_rust::main_config(xyz_rust::Config {
// serve/mcp/help are reserved words; renaming releases the old words for use as commands
modes: xyz_rust::ModeWords { serve: "httpd".into(), mcp: "protocol".into(), help: "assist".into() },
// disabling a channel removes only its runtime path: mcp/serve/help/-v always survive
capabilities: xyz_rust::Capabilities { no_cli: true, ..Default::default() },
..Default::default()
})
```
With `no_cli`, user subcommands disappear but `mcp stdio`, `serve`, `help` and `-v` keep working (the overview annotates "disabled" and hides the command table); the disabled channel's `.cli()/.http()/.mcp()` configuration still compiles and runs — it simply stops being consumed by a frontend that is switched off. A registry with zero registered commands is a silent no-op (exit 0). Capability switches are runtime knobs, independent of the compile-time Cargo features.
## Built-in configuration (`--xyz.*` and `Config` fields)
The library's own settings live in `Config` fields and the `--xyz.*` command-line namespace; precedence: **mode-local flag > global `--xyz.*` / code Config > library defaults**. Inside `serve`/`mcp` the mode word _is_ the namespace, so built-ins use bare names (`--bearer`, `--addr`, `--cors`, …), and the prefixed `--xyz.*` forms work anywhere before the `--` terminator. Renaming the mode words migrates the namespace with them.
| Parameter | Code field | Meaning |
|---|---|---|
| `--bearer=tok1,tok2` (or `--bearer tok`) | `Config.bearer_tokens` | Bearer verification for **serve REST** and **MCP http**: `Authorization: Bearer <tok>` must hit one of the tokens, else 401 + `{"error":"unauthorized"}`; empty = no auth. stdio is a local process and unaffected (a note is logged) |
| `--addr=:8080` | `Config.addr` | Default listen address for serve and mcp http |
| `--log-level=debug` (or `--xyz.log-level`) | `Config.log_level` | Library diagnostics to stderr (`xyz[level]:` prefix): `debug`/`info`/`warn`/`error`, default `info`. Command results and usage errors are unaffected |
| `--timeout=45s` | `Config.timeout` | serve per-request timeout (a `tower-http` TimeoutLayer answering 408); 0 = no timeout layer |
| `--tls-cert/--tls-key` | `Config.cert_file`/`key_file` | serve switches to TLS when both are given |
| `--cors=https://a,b` (or `*`) | `Config.cors_origins` | CORS allowlist for serve and MCP http; OPTIONS preflights answer before auth (browser preflights carry no credentials) |
| `--session-timeout=30m` (mcp only) | `mcp::Options.session_timeout` | Idle-session expiry for streamable HTTP (the SDK's session keep-alive) |
```bash
xyz-example serve --bearer=s3cret # REST/openapi/mcp all require credentials
xyz-example mcp http --addr :9000 --bearer a,b # standalone MCP, same scheme
xyz-example mcp http --xyz.bearer a,b # prefixed form is equivalent
```
MCP's own flags: `--versions/--name/--server-version/--addr/--json-response/--stateless/--session-timeout/--bearer/--cors`. CLI owns `--json`, `-h`, `-v`.
Waiting list (kept out to stay minimal — each lands in one iteration when asked): log file rotation, basic rate limiting.
## Embedding & multiple registries
Besides the singleton chain, the parameterized pure functions (which return the exit code without exiting) serve embedding, tests and multi-registry setups:
```rust
let reg = xyz_rust::Registry::new();
xyz_rust::spec::command::Command::new("user.add", add_user)
.summary("...")
.register(®)?; // explicit path
std::process::exit(xyz_rust::run(®, args)); // for deferred cleanup in main
let srv = xyz_rust::mcp::server(®, xyz_rust::mcp::Options {
versions: vec![xyz_rust::mcp::PROTOCOL_V2026_07_28.into()],
..Default::default()
}, ctx)?;
let router = xyz_rust::httpapi::router(®, ctx)?; // mount all HTTP routes (healthz & openapi included)
let one = xyz_rust::httpapi::handler_for(entry); // mount one command on any axum Router (entry: Arc<Entry>)
let env = xyz_rust::env(); // §14 item 7: { language, interactive, no_color }
let lang = xyz_rust::language_from_ctx(ctx); // per-request language (§11.7)
let mut app = xyz_rust::cli::App::new_with_options(®, xyz_rust::cli::Options::default())?;
// app.set_output(Some(out_writer), Some(err_writer)); // redirect output streams
app.use_mw(Box::new(mw)); // Execute middleware: rewrite args, short-circuit, wrap next()
xyz_rust::mcp::run_context(&ctx, ®, args);
xyz_rust::cli::run_context(&ctx, ®, args, xyz_rust::cli::Options::default());
```
The dispatch-level entry points are `main_entry(&[&dyn Definable])` / `main_config(cfg)` (which exit) and `run(®, args)` / `run_config(®, args, cfg)` (which return the exit code).
Already on clap / axum? See the [migration guide](docs/adapters.md) with a runnable example in [`examples/clap`](examples/clap) — three coexistence levels (tier A: replace the frontend and keep the `Entry.invoke` spine; tier B: mount per-command handlers via `httpapi::handler_for` / `cli::App`; tier C: reuse parts like `httpapi::middleware` or `cli::render`), all without touching the core.
## Differences from the Go implementation
1. **No SSE transport.** The official Rust SDK (rmcp 3.6.0) removed the HTTP+SSE transport with the 2026-07-28 revision, so `mcp sse` is parsed but answered with a clear error and exit code 2. Available transports: `stdio` and streamable `http`.
2. **serde enters the core.** Rust's std has no JSON, so the core (spec / registry / errors / cli / logx / root) depends on serde + serde_json — the "missing standard library" — and chrono (time types), and nothing else. `httpapi` sits on axum (the de-facto standard HTTP stack in Rust, the same stack the official rmcp streamable-HTTP examples use), and `mcp` on the official Rust SDK `rmcp` pinned to `=3.6.0` — the only other direct third-party dependency tree. `http` and `mcp` share the tokio+axum cluster behind the internal `http-stack` feature.
3. **No reflection.** What Go derives at runtime from struct tags is generated at compile time by the derive macros. The attribute vocabulary is `#[xyz(desc="...", name="w", required, secret, skip, validate="min=2,email", default="18", enum="a,b", cli="positional"/"shorthand=a,env=X"/"hidden"/"-", http="query|path|header|form|body", http_name="X-Key")]` — one-to-one with the Go tags, plus the serde `rename` fallback and `rename_all` support. Named scalar newtypes derive `XyzField`; result structs derive `Serialize` + `XyzOutput` with wire names following serde conventions, or no derive at all — a `XyzArgs` input struct automatically provides `XyzSchema`.
4. **Handler shape.** `fn(handler(_: &Ctx, _: &Args) -> Result<Resp, E>)` with `E: std::error::Error` (classification on the error chain is preserved) and `R: Serialize`. `define("name", h)` is fully inferred — no Go-style explicit `Define[T, R]` generics.
5. **Result rendering.** Structs and maps are the same shape: both become a `serde_json::Value` after serialization, and `preserve_order` keeps declaration order (Go sorts map keys); a `Vec<u8>` result type gets an array-shaped output schema (its input side is still `string`); `std::time::Duration` has no negative-value support (Rust semantics); `oneof` has no `%v` form for structs; in `--format markdown` a struct and a map are indistinguishable after serialization, so both use the `| Field | Value |` header (Go uses `| Key | Value |` for maps).
6. **Version injection.** Call `set_version("v1.2.3")` at release time — Rust has no `-ldflags -X`; the default *application* version is `dev` (xyz-spec §12.6). The SDK's own version (`version::SDK_VERSION`, crate version) is reported apart on HTTP `X-XYZ-Version` and `_meta.xyz.sdk_version`.
7. **HTTP semantics.** Gzip via `tower-http` (compresses any response size and handles `Accept-Encoding` q-values; the Go port only checks the header); per-request timeout via `TimeoutLayer` answering **408** (not 504); request-level cancellation: a client disconnect does not interrupt the running handler; the standard header timeout is not configured (a non-zero `Config.timeout` is the only timeout layer).
8. **MCP differences.** `--versions` accepts the same full set as Go — `2024-11-05`, `2025-03-26`, `2025-06-18`, `2025-11-25`, `2026-07-28` (newest) — but version pinning is handed to the SDK's negotiation via `supported_protocol_versions`; streamable HTTP serves 2026-07-28 only with `--stateless`; and the SDK's streamable-HTTP server allows only loopback `Host` headers by default (rmcp's DNS-rebinding protection). MCP structured output is gated the same way as the spec now requires (object-only `outputSchema`, object-only `structuredContent`, revisions ≥ 2025-06-18) whereas Go attaches `outputSchema` for every schematisable result type and always fills `structuredContent` with the bare value — filed as `D-go-04`. Verified upstream limitations in rmcp 3.6.0 (reported from ddc's protocol testing; no xyz-side wrap can fix them without forking the transport): a `tools/call` without `params` answers `-32601` instead of `-32602`; a malformed JSON body answers HTTP 415 instead of 400; deleting a session twice answers 202 both times; a request with `id: null` answers `-32600` rather than echoing it. Fixed by the 3.6.0 upgrade: `server/discover` without `params` now answers `-32602` (was `-32601`), float-form ids are read as integers, and non-JSON stdio lines are consistently ignored. The `initialize` capability object differs in scope by SDK default: Go's go-sdk auto-emits `{"logging":{},"tools":{"listChanged":true}}`, while xyz-rust declares the truthful minimum `{"tools":{}}` (no list-changed notifications are emitted).
9. **Capability switches stay available at runtime**: `Capabilities { no_cli, no_mcp, no_http }` are `Config` fields, independent of the Cargo features.
All of the above are filed, with spec-section references, in the spec repository's [deviations register](https://github.com/ejfkdev/xyz-spec/blob/main/deviations.md) (`D-rust-01` … `D-rust-13`) and attested in [CONFORMANCE.md](CONFORMANCE.md).
## Dependency policy & binary size
- **Serde-family-only core**: the core modules have zero third-party dependencies beyond serde, serde_json and chrono; the CLI frontend is std + serde (ctrlc only for signals in non-HTTP builds); `httpapi` needs the axum cluster (`http-stack`: tokio, axum, axum-server, tower-http, rustls; TLS runs on the `ring` provider — axum-server is built with `tls-rustls-no-provider` and `ring` is installed explicitly at startup, so an embedding graph that also carries `aws-lc-rs` stays deterministic); the only third-party tree on top is the official `rmcp` (pinned `=3.6.0`, shared by `mcp`).
- **Trim any channel via Cargo features** (any combination):
| Features | Channels | Size (macOS arm64, `cargo build --release` + strip, `xyz-example`) |
|---|---|---|
| default (`cli,http,mcp`) | CLI + HTTP + MCP | 7.9M |
| `--no-default-features --features cli,http` | CLI + HTTP | 4.3M |
| `--no-default-features --features http,mcp` | HTTP + MCP | 7.8M |
| `--no-default-features --features cli,mcp` | CLI + MCP | 6.4M |
| `--no-default-features --features cli` | CLI only | 1.1M |
| `--no-default-features` | embedding only | 0.95M |
```bash
cargo build --release
cargo build --release --no-default-features --features cli # CLI only
cargo build --release --no-default-features # embedding only
strip target/release/xyz-example
```
For reference, the same showcase in Go measures 9.4M / 7.6M / 9.4M / 8.8M / 5.1M / 4.4M (`go build -ldflags="-s -w"` + strip) — Rust comes out smaller because there is no Go runtime floor: every Go binary pays ≈1.1M just for the runtime, while a Rust binary starts from the library itself. The trimmed channel disappears as a block; invoking a channel that was not compiled in answers a clear error and exits 1.
## Module layout
| Module | Responsibility | Dependencies |
|---|---|---|
| `src/lib.rs` (root crate `xyz_rust`) | fluent `define` chain, mode dispatch, capability switches, built-in parameters (`--xyz.*`), version | std + serde |
| `src/spec` | generic definition (derive-macro generated), decode pipeline, validation, JSON Schema | std + serde/serde_json + chrono |
| `src/registry` | command table: registration, conflict checks, the process-level singleton | std |
| `src/errors` | error taxonomy and three-interface mappings | std |
| `src/cli` | CLI frontend: command tree, flag parsing, help, rendering, completion | std + serde (+ctrlc) |
| `src/httpapi` | HTTP frontend: routing, binding, middleware, openapi.json | axum cluster |
| `src/logx` | leveled diagnostics to stderr (`xyz[level]:` prefix) | std |
| `src/mcp` | MCP frontend: stdio/streamable HTTP transports, protocol versions | rmcp |
| `src/config`, `src/builtins`, `src/dispatch`, `src/ctx`, `src/overview`, `src/version`, `src/builder` | configuration model, `--xyz.*` parsing, root dispatcher, cancellation context, overview rendering, version string, fluent chain | std |
| `xyz-rust-macros/` | derive macros `XyzArgs` / `XyzField` / `XyzOutput` | syn + quote |
| `examples/example`, `examples/tour`, `examples/clap` | showcase, internal tour, clap coexistence | — |
## Design principles
1. **Zero boilerplate by default, explicit paths always available**: the singleton chain is the main entry; registry-parameterized pure functions are the backdoor for embedding and tests — both share one dispatch and invoke pipeline.
2. **Fail at registration**: bad names/types/attributes/route conflicts/unsupported validation rules surface at startup, never at runtime.
3. **Configuration is data; frontends are consumers**: `.cli()/.http()/.mcp()` only store metadata, so Cargo features and capability switches never break compilation.
4. **Envelope-free, natural forms**: machines read JSON, humans read tables, from one return value.
5. **The shell is uncuttable**: `help`/`-v`/mode words/`completion` work in every combination.
6. **The mode word is the namespace**: built-ins in `serve`/`mcp` use bare names; `--xyz.*` works globally; library messages never hardcode mode words — renaming migrates everything.
7. **Cancellation flows everywhere**: the dispatcher owns a signal `Ctx` that reaches CLI/HTTP/MCP handlers; HTTP and MCP servers drain in-flight requests (graceful window) before exiting.
## Development
```bash
cargo test # unit/integration tests across the library (default features)
cargo test --no-default-features --features cli,http # every trimmed feature combination also passes (6 combos)
cargo build --workspace # all crates incl. macros + examples
cargo run -p xyz-example # full showcase
cargo run -p xyz-tour # internal-walkthrough tour
cargo run -p xyz-clap -- add bob # clap coexistence example
```
Output contract: command results go to stdout; errors and diagnostics go to stderr (diagnostics carry the `xyz[level]:` prefix, level via `--log-level`); under the stdio transport stdout is reserved for protocol frames. The `Ctx` passed to handlers is canceled on SIGINT/SIGTERM (the HTTP/MCP servers drain in-flight requests first).
### Release
```bash
cargo package # sanity check before publishing
git tag v0.1.0 && git push origin v0.1.0 # consumers: cargo add xyz-rust (first publish: cargo publish)
```
> Also available: [中文文档](README.zh-CN.md)
Release process: see [RELEASING.md](RELEASING.md) — every tag carries a full changelog (the annotated message *is* the release note).