Skip to content

Server Transports

gen_servers takes your API surface — the merged ApiOutput from gen_api — and generates server-side handler code for up to three transport targets. Each transport produces the same operations with the same behavior, just adapted to its protocol.

TransportProtocolOutputUse case
HTTP/AxumREST over HTTPAxum route handlers + router functionWeb APIs, external integrations
Tauri IPCInter-process communicationTauri #[command] handlers + ipc_handler()Desktop apps with Tauri
MCPModel Context ProtocolTool definitions + handle_tool_call()AI assistant integrations

You enable each transport independently through ServerGeneratorConfig variants. Most projects use one or two; having all three is for applications that serve web clients, desktop clients, and AI tools from the same codebase.

let servers_output = ontogen::gen_servers(
Some(&api_output),
&["src/api/v1".into()],
&ontogen::ServersConfig {
api_dir: "src/api/v1".into(),
state_type: "AppState".to_string(),
service_import_path: "crate::api::v1".to_string(),
types_import_path: "crate::schema".to_string(),
state_import: "crate::AppState".to_string(),
naming: ontogen::servers::NamingConfig::default(),
generators: vec![
ontogen::servers::ServerGenerator::HttpAxum {
output: "src/api/transport/http/generated.rs".into(),
},
ontogen::servers::ServerGenerator::TauriIpc {
output: "src/api/transport/ipc/generated.rs".into(),
},
ontogen::servers::ServerGenerator::Mcp {
output: "src/api/transport/mcp/generated.rs".into(),
},
],
rustfmt_edition: "2024".to_string(),
sse_route_overrides: Default::default(),
route_prefix: None,
store_type: Some("Store".to_string()),
store_import: Some("crate::store::Store".to_string()),
pagination: None,
extra_surfaces: vec![],
},
)?;

Let’s walk through the important fields:

FieldPurpose
api_dirDirectory to scan for API source files
state_typeYour AppState type name, used in handler signatures
service_import_pathRust import path for API modules (e.g., crate::api::v1)
types_import_pathImport path for schema/DTO types
state_importFull import path for the state type
namingPluralization and URL naming overrides
generatorsWhich server transports to generate
rustfmt_editionRust edition used when formatting the generated Rust
store_typeStore type name for entity-scoped functions
store_importFull import path for the Store type
route_prefixOptional scope prepended to every route — see Route prefix
paginationOptional pagination for list operations

The full field list is in the ServersConfig reference.

The HTTP generator produces Axum route handlers with proper HTTP methods, paths, and request/response types.

Each OpKind maps to a specific HTTP method and path pattern:

OperationMethodRoute patternExample
ListGET/api/{entities}GET /api/tasks
GetByIdGET/api/{entities}/{id}GET /api/tasks/{id}
CreatePOST/api/{entities}POST /api/tasks
UpdatePUT/api/{entities}/{id}PUT /api/tasks/{id}
DeleteDELETE/api/{entities}/{id}DELETE /api/tasks/{id}
CustomGetGET/api/{entities}/{action}GET /api/tasks/overdue
CustomPostPOST/api/{entities}/{action}POST /api/tasks/close-by-project
JunctionListGET/api/{parents}/{id}/{children}GET /api/agents/{id}/roles
JunctionAddPOST/api/{parents}/{id}/{children}POST /api/agents/{id}/roles
JunctionRemoveDELETE/api/{parents}/{id}/{children}/{child_id}DELETE /api/agents/{id}/roles/{child_id}
EventStreamGET (SSE)/api/events/{name}[/{param}]GET /api/events/vault-changes/{vault_id}

Entity names in URLs are pluralized and kebab-cased automatically: work_session becomes work-sessions, unit_of_work becomes units-of-work.

Here’s what a generated list handler looks like:

async fn task_list(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Task>>, ApiError> {
let store = state.store().await.map_err(|e| err(e.to_string()))?;
task::list(store)
.await
.map(Json)
.map_err(|e| err(e.to_string()))
}

The generator also produces a router() function that wires all handlers to their routes:

pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/api/tasks", get(task_list).post(task_create))
.route("/api/tasks/{id}", get(task_get_by_id).put(task_update).delete(task_delete))
// ... more routes
}

When you set pagination in your config, all List operations get limit and offset query parameters:

pagination: Some(ontogen::servers::PaginationConfig {
default_limit: 50,
max_limit: 200,
}),

List handlers then return PaginatedResult<T> instead of Vec<T>:

#[derive(Serialize)]
pub struct PaginatedResult<T: Serialize> {
pub items: Vec<T>,
pub total: u64,
pub limit: u32,
pub offset: u32,
}

The pagination wraps the service call result — it fetches all items, then applies limit/offset in memory. The total field reflects the unfiltered count so clients can calculate page counts.

The IPC generator produces #[tauri::command] handlers for desktop applications. Each command follows an entity-first naming convention:

API functionIPC command name
task::listtask_list
task::get_by_idtask_get_by_id
task::createtask_create
agent::add_roleagent_add_role
task::get_overduetask_get_overdue
#[tauri::command]
pub async fn task_list(
state: State<'_, Arc<AppState>>,
) -> Result<Vec<Task>, String> {
let store = state.store().await.map_err(|e| e.to_string())?;
task::list(store).await
.map_err(|e| e.to_string())
}

The generator also produces an ipc_handler() function that wraps all commands into a tauri::generate_handler! macro call:

pub fn ipc_handler() -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static {
tauri::generate_handler![
task_list,
task_get_by_id,
task_create,
task_update,
task_delete,
// ... all commands
]
}

Wire this into your Tauri builder:

tauri::Builder::default()
.invoke_handler(generated::ipc_handler())
.run(tauri::generate_context!())

An API function that returns broadcast::Receiver<T> is an event op. It may be async, may return Result<broadcast::Receiver<T>, E>, and may take parameters after the state:

pub async fn vault_changes(
state: &AppState,
vault_id: String,
classes: Option<String>,
resume: Option<String>,
) -> Result<broadcast::Receiver<LoggedChange>, AppError>;
  • HTTP. An SSE route. Required params are path segments and Option params are query params, as for a CustomGet: GET /api/events/vault-changes/{vault_id}?classes=…. A failed subscribe is an error response.
  • Resume. A param named resume of type Option<String> makes the op resumable. The handler fills it from the Last-Event-ID header, or the resume query param when the header is absent. The item type must implement ontogen_core::events::EventSeq; its event_id() is written as each event’s id:. Ontogen does not replay history; the function decides what resuming means.
  • Lag. When a subscriber falls behind the broadcast, it receives an event: lag frame with data: {"skipped": n} and the stream stays open.
  • IPC. Each event op generates {fn}_subscribe(args…, channel) and {fn}_unsubscribe(id). Subscribe forwards EventFrame<T> messages ({"kind":"event","id":…,"data":…} or {"kind":"lag","skipped":n}) into the caller’s tauri::ipc::Channel and returns a subscription id. The forwarding task ends on unsubscribe, on the first failed send (the webview is gone), or when the senders close. Both commands are in ipc_handler().

The generated code uses ontogen-core’s runtime support, so a crate with event ops depends on it with the events feature, plus tokio and futures:

ontogen-core = { version = "…", features = ["events"] }

For the parameterless sync shape (fn f(state: &AppState) -> broadcast::Receiver<T>), the IPC generator also keeps start_event_forwarding, which emits every such stream to all windows as a Tauri event. Prefer the subscribe commands: they report lag to the subscriber and end with it.

The MCP (Model Context Protocol) generator produces tool definitions for AI assistants. Each API function becomes an MCP tool with a JSON Schema input definition, a description, and an async handler.

pub struct McpToolDef {
pub name: &'static str,
pub description: &'static str,
pub schema_fn: fn() -> Value,
pub handler: HandlerFn,
}

The generated registry provides three entry points:

  • generated_tool_registry() — returns Vec<McpToolDef> with live handlers for tool execution.
  • tool_definitions() — returns Vec<SimpleToolDef> with pre-computed schemas, suitable for tools/list responses.
  • handle_tool_call(state, tool_name, args) — dispatches a tool call by name. Creates a minimal tokio runtime for sync MCP servers.

MCP tool names follow the same entity-first convention as IPC commands: task_list, task_create, agent_add_role.

URL paths and command names are derived from module names using the cruet crate for Rails-style inflection. The NamingConfig lets you override the defaults when the inflector gets it wrong.

naming: ontogen::servers::NamingConfig {
plural_overrides: HashMap::from([
("evidence".to_string(), "evidence".to_string()), // uncountable
]),
singular_overrides: HashMap::from([
("work_sessions".to_string(), "session".to_string()),
]),
label_overrides: Default::default(),
plural_label_overrides: Default::default(),
},

The naming system provides several derived forms:

MethodInputOutputUsed for
url_plural"work_session""work-sessions"HTTP route paths
url_singular"work_sessions""session"IPC command prefixes
module_plural"evidence""evidence"Rust code references
label"work_session""Work Session"Admin UI labels
plural_label"evidence""Evidence"Admin UI plural labels

For custom functions, derive_action strips the module name from the function name and converts to kebab-case: get_overdue_tasks on the task module becomes the action segment overdue.

By default, event stream functions get routes under /api/events/{kebab-name}. You can override individual routes:

sse_route_overrides: HashMap::from([
("graph_updated".to_string(), "/api/events/graph".to_string()),
]),

When your app manages multiple projects, you can prepend a project scope to all entity routes:

route_prefix: Some(ontogen::servers::RoutePrefix {
// Colon-style here -- see the note below.
segments: "projects/:project_id".to_string(),
state_accessor: "store_for".to_string(),
params: vec![
ontogen::servers::PrefixParam {
name: "project_id".to_string(),
rust_type: "uuid::Uuid".to_string(),
ts_type: "string".to_string(),
},
],
}),

This generates scoped routes like /api/projects/{project_id}/tasks alongside the entity handlers. The state_accessor method is called to construct a project-scoped Store from the extracted path parameter.

Each transport writes to a single file specified in its config:

src/api/transport/
http/
generated.rs # Axum routes + router()
ipc/
generated.rs # Tauri commands + ipc_handler()
mcp/
generated.rs # MCP tools + tool_definitions() + handle_tool_call()

All three files are regenerated on every build. Don’t edit them.