Server-less takes an impl-first approach: write your Rust methods, derive macros project them into protocols.
impl MyService {
/// Create a new user
fn create_user(&self, name: String, email: String) -> Result<User, UserError> { ... }
/// Get user by ID
fn get_user(&self, id: UserId) -> Option<User> { ... }
/// List all users
fn list_users(&self, limit: u32, offset: u32) -> Vec<User> { ... }
/// Watch for new users
fn watch_users(&self) -> impl Stream<Item=User> { ... }
}Each derive macro projects this into a protocol:
| Derive | Projection |
|---|---|
Http |
REST endpoints |
Grpc |
Protobuf service |
GraphQL |
Schema + resolvers |
Cli |
Command-line interface |
Mcp |
MCP tools for LLMs |
Client (planned) |
Type-safe client SDK |
OpenApi |
Spec from types + docs |
| Method prefix | HTTP verb | Path pattern |
|---|---|---|
create_*, add_* |
POST | /resources |
get_*, fetch_* |
GET | /resources/{id} |
list_*, find_*, search_* |
GET | /resources |
update_*, set_* |
PUT | /resources/{id} |
delete_*, remove_* |
DELETE | /resources/{id} |
| anything else | POST | /rpc/{method_name} |
fn get_user(id: UserId) -> User
// ^^^ path param: ends with "Id" or named "id"
fn list_users(limit: u32, offset: u32) -> Vec<User>
// ^^^ query params: GET + primitive types
fn create_user(name: String, email: String) -> User
// ^^^ body: POST/PUT + non-id paramsfn create_user(name: String, email: String) -> User
// → myapp create-user --name "..." --email "..."
fn get_user(id: UserId) -> Option<User>
// → myapp get-user <ID>
// ^^^ positional: single id-like arg/// Search for users by name
fn search_users(&self, query: String, limit: Option<u32>) -> Vec<User>Becomes:
{
"name": "search_users",
"description": "Search for users by name",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"limit": { "type": "integer" }
},
"required": ["query"]
}
}- Method name → tool name
- Doc comment → tool description
- Parameters → input schema
Option<T>→ optional parameter- Return type → tool result
The return type is the API contract:
| Type | Meaning | HTTP | CLI |
|---|---|---|---|
T |
Success with data | 200 + body | stdout |
Option<T> |
Maybe not found | 200 or 404 | stdout or exit 1 |
Result<T, E> |
Success or typed error | 200 or error from E | stdout or stderr |
() |
Success, no data | 204 No Content | silent |
Vec<T> |
Collection (collected) | 200 + JSON array | JSON array |
impl Iterator<Item=T> |
Lazy sequence | SSE (via stream::iter) |
newline-delimited (streamed) |
impl Stream<Item=T> |
Async streaming | SSE/WebSocket | newline-delimited |
impl Stream<Item=T> means streaming. For non-streaming protocols:
#[http(collect)] // explicitly opt-in to collecting
fn watch_events(&self) -> impl Stream<Item=Event>Without #[http(collect)], using Http derive on a streaming method is a compile error. This avoids hidden memory bombs.
Errors are typed via Result<T, E>:
enum UserError {
NotFound, // → 404 (convention: contains "NotFound")
InvalidEmail, // → 400 (convention: "Invalid*")
Forbidden, // → 403 (exact match)
AlreadyExists, // → 409 (convention: "AlreadyExists", "*Conflict")
}Or explicit mapping:
#[derive(Error)]
enum UserError {
#[error(http = 404, grpc = "NOT_FOUND")]
NotFound,
}Each protocol derive maps errors appropriately:
- HTTP → status code + JSON body
- gRPC → status code + details
- CLI → stderr + exit code
- GraphQL → errors array
- MCP → error response
Conventions work 80% of the time. Override when needed:
#[route(path = "/api/v1/users", method = "POST")]
fn register(&self, ...) -> User // wouldn't infer POST from "register"
#[cli(name = "add")]
fn create_user(&self, ...) -> User // override CLI subcommand name
#[mcp(name = "find_users", description = "Search the user database")]
fn search_users(&self, ...) -> Vec<User> // override MCP tool metadataSome protocols need context (headers, metadata, etc.):
fn create_user(
&self,
ctx: Context, // injected: HTTP headers, gRPC metadata, CLI env, etc.
name: String,
email: String,
) -> Result<User, UserError>Context is protocol-agnostic. Each derive provides the relevant data:
- HTTP: headers, cookies, query params
- gRPC: metadata
- CLI: env vars, config files
- MCP: conversation context (if available)
cli_run() is the batteries-included sync entrypoint. When async methods are present,
it internally creates a Tokio runtime to drive them. Tokio is the chosen battery —
this is a deliberate default, not an accident.
cli_run_async() is the runtime-agnostic escape hatch: it awaits the dispatched method
directly without importing any runtime. Users who prefer async-std, smol, or want to
control runtime configuration use this:
#[tokio::main] // or async_std::main, smol::block_on, etc.
async fn main() {
app.cli_run_async().await.unwrap();
}Why not make the runtime configurable? A #[cli(runtime = "async-std")] attribute
would enumerate runtimes in the macro and generate different block_on call sites per
choice — complexity with no real payoff. cli_run_async() already gives full control
with zero macro complexity. Users who don't want Tokio use cli_run_async(); users who
want to drop the entrypoints entirely use #[cli(no_sync)] / #[cli(no_async)].
Settled: tokio as default battery, cli_run_async() as the escape hatch. Do not
revisit by adding a runtime attribute.