API Reference
Reference for the Rust crates. Full rustdoc with all generics, trait bounds, and method signatures is published on docs.rs — this page is a high-level cheatsheet.
| Crate | docs.rs | crates.io | Purpose |
|---|---|---|---|
coralstack-cmd-ipc | docs.rs | crates.io | Core registry, channels, protocol |
coralstack-cmd-ipc-macros | docs.rs | crates.io | #[command] / #[command_service] / #[event] / #[payload] attribute macros |
coralstack-cmd-ipc-mcp | docs.rs | crates.io | MCP server adapter (via rmcp) |
Prelude
Section titled “Prelude”use coralstack_cmd_ipc::prelude::*;Brings into scope: command, command_service, event, payload, BoxedDynCommand, ChannelError, Command, CommandChannel, CommandDef, CommandError, CommandRegistry, CommandSchema, Config, DynCommand, DynEvent, Event, InMemoryChannel.
CommandRegistry
Section titled “CommandRegistry”The central hub — mirrors the TypeScript CommandRegistry 1:1.
use coralstack_cmd_ipc::prelude::*;use std::time::Duration;
let registry = CommandRegistry::new(Config { id: Some("main".into()), router_channel: None, request_ttl: Duration::from_secs(30), event_ttl: Duration::from_secs(5), max_in_flight_per_channel: 256,});Config
Section titled “Config”| Field | Type | Description |
|---|---|---|
id | Option<String> | Registry identifier for logging |
router_channel | Option<String> | Channel ID for escalating unknown commands |
request_ttl | Duration | Timeout for pending requests |
event_ttl | Duration | TTL for event deduplication |
max_in_flight_per_channel | usize | Per-channel cap on concurrently in-flight handler futures (default 256; 0 disables) |
Every field has a sensible default — call Config::default() or use ..Default::default() in a struct literal and you can omit anything you don’t care about:
let registry = CommandRegistry::new(Config { id: Some("main".into()), ..Default::default() // fills in router_channel, request_ttl, event_ttl, max_in_flight_per_channel});The fields are only syntactically required when you list them out one by one (Rust struct literals must name every field). Functionally none of them are mandatory.
Methods
Section titled “Methods”| Method | Mirrors TS | Notes |
|---|---|---|
register_command(cmd) | registerCommand | Single entry point. Takes any C: Command — typed #[command]-macro structs for compile-time commands, DynCommand for runtime-constructed commands |
register_channel(channel) | registerChannel | Returns a driver future — spawn it on your executor |
execute::<C>(req) | strict executeCommand<K> | C: Command pins request and response types |
execute_dyn(id, req: Value) | loose executeCommand | Raw JSON in, raw JSON out — for runtime-known ids |
emit(event) | emitEvent | Single entry point. Takes any E: Event — typed #[event]-macro structs or DynEvent |
on::<E>(cb) | typed addEventListener | E: Event + DeserializeOwned; callback receives deserialized E |
on_dyn(id, cb) | dynamic addEventListener | Runtime id; callback receives raw Value |
list_commands() | listCommands | Returns Vec<CommandDef> |
list_channels() | listChannels | Returns connected channel IDs |
dispose().await | dispose | Async — awaits each channel.close() before returning |
id() | id property | Registry identifier |
Driving channels
Section titled “Driving channels”register_channel does not spawn anything — it returns a future you own. Poll it with whatever executor you run elsewhere:
// Tokiolet driver = registry.register_channel(channel).await?;tokio::spawn(driver);
// futures::executor / smol / async-std — same shape, different spawner.Forgetting to spawn the driver means the registry never processes incoming messages from that channel. dispose() will still work (it awaits each channel.close()), but until then the channel is silent from the registry’s point of view.
Concurrent handler dispatch
Section titled “Concurrent handler dispatch”Within the driver task, the pump dispatches handlers concurrently: each incoming message’s handler future is pushed into a FuturesUnordered and cooperatively interleaved with the next recv. A handler that awaits external work (timer, network, forwarded call) no longer blocks subsequent messages on the same channel — fast commands, forwarded responses, and events all continue flowing.
The number of simultaneously in-flight handlers on one channel is capped by Config::max_in_flight_per_channel (default 256). At the cap the pump applies backpressure to the channel rather than dropping messages; set to 0 to disable the cap.
This is cooperative concurrency on a single executor task, not multi-thread parallelism. For true CPU-parallel dispatch, wrap your handler body in your runtime’s spawn (e.g. tokio::spawn) — the crate itself stays runtime-agnostic.
request_ttl vs handler duration
Section titled “request_ttl vs handler duration”The registry rejects pending execute calls after request_ttl with a Timeout error. Late responses that arrive after expiry find no pending handler and are silently dropped (per spec). For slow handlers — long JS execution in a plugin, large HTTP fetches — raise request_ttl above the worst-case handler duration, or enforce a shorter timeout on the handler side.
Void commands and events
Section titled “Void commands and events”Model void as ():
#[command("worker.reset")]async fn reset(&self) -> Result<(), CommandError> { /* ... */ Ok(()) }
// Unit struct = void event#[event("worker.tick")]struct WorkerTick;The macro detects () for request or response and omits that slot from the advertised schema. On the wire the registry omits the request, result, and payload fields entirely (rather than sending null), per the JSON schemas in spec/.
Command trait
Section titled “Command trait”Strict-mode typing — the Rust equivalent of CommandSchemaMap.
use coralstack_cmd_ipc::prelude::*;
#[payload]struct AddReq { a: i64, b: i64 }
struct Add;
impl Command for Add { const ID: &'static str = "math.add"; const DESCRIPTION: Option<&'static str> = Some("Add two integers"); type Request = AddReq; type Response = i64;
async fn handle(&self, req: AddReq) -> Result<i64, CommandError> { Ok(req.a + req.b) }
// Optional — omit to skip schema advertising. Without a schema, // cross-language peers fall back to permissive validation. fn schema(&self) -> Option<CommandSchema> { Some(CommandSchema::empty().with_request( serde_json::to_value(coralstack_cmd_ipc::schemars::schema_for!(AddReq)).unwrap(), )) }}
// Register the typed command. The id, description, schema, and// request/response adapter are all pulled from the `Command` impl.registry.register_command(Add).await?;
// Strict call — request and response types checked at compile time.let result: i64 = registry.execute::<Add>(AddReq { a: 2, b: 3 }).await?;In practice you’d use #[command("math.add")] async fn add(&self, req: AddReq) -> Result<i64, CommandError> { ... } — the #[command] / #[command_service] macros generate the Command impl (including schema()) and a register_* / .register(®istry) helper that calls register_command for you. See Defining Commands.
Runtime-constructed commands: DynCommand
Section titled “Runtime-constructed commands: DynCommand”When the id or schema is only known at runtime (plugin runtimes, FFI, scripting hosts), build a DynCommand and register it the same way. There is no separate “dyn” registration method — DynCommand implements Command, so it goes through register_command alongside typed commands:
use serde_json::{json, Value};
// Dynamic id, `Value` in/out — simplest form.let cmd = DynCommand::new(runtime_id, |req: Value| async move { Ok(json!({ "echoed": req }))});registry.register_command(cmd).await?;
// Dynamic id, typed in/out — works too: types are inferred from the closure.let cmd = DynCommand::new(runtime_id, |req: AddReq| async move { Ok(req.a + req.b)}).description("Runtime adder").request_schema(runtime_request_schema).response_schema(runtime_response_schema);registry.register_command(cmd).await?;Builders:
.description(..)— human-readable description surfaced to MCP clients andlist_commands..schema(CommandSchema)— attach a full request+response schema pair..request_schema(Value)/.response_schema(Value)— set one slot at a time.
For a Vec of heterogeneous runtime commands (a plugin host holding many handlers), use BoxedDynCommand:
use coralstack_cmd_ipc::BoxedDynCommand;use serde_json::json;
let a: BoxedDynCommand = DynCommand::boxed("plugin.hello", |_| async { Ok(json!("hi")) });let b: BoxedDynCommand = DynCommand::boxed("plugin.bye", |_| async { Ok(json!("bye")) });let commands: Vec<BoxedDynCommand> = vec![a, b];for cmd in commands { registry.register_command(cmd).await?;}DynCommand::boxed erases the handler’s concrete closure type into BoxedHandler, giving every command the same static type so they can share a collection.
Permissive schemas for plugins
Section titled “Permissive schemas for plugins”CommandSchema::permissive() advertises a fully open any → any schema — the right default when a plugin’s exported function has no introspectable types (e.g. a QuickJS function(req) { ... }). CommandSchema::empty() advertises no schema on either slot (void/void); attach real schemas via .with_request(..) / .with_response(..).
Event trait
Section titled “Event trait”Typed events — the event payload struct is the event. Attach #[event("id")] to a struct and it becomes a typed event usable with emit / on. The macro auto-derives Serialize / Deserialize / JsonSchema against the re-exports from coralstack-cmd-ipc, so user crates don’t add those dependencies themselves.
use coralstack_cmd_ipc::prelude::*;
/// Worker has finished initializing.#[event("worker.ready")]pub struct WorkerReady { pub worker_id: String, pub command_count: u32,}
// Void event — a unit struct emits no payload on the wire.#[event("worker.tick")]pub struct WorkerTick;
// Emit — fully typed.registry.emit(WorkerReady { worker_id: "w1".into(), command_count: 2,})?;registry.emit(WorkerTick)?;
// Listen — callback receives a deserialized `WorkerReady`.let _unsub = registry.on::<WorkerReady>(|event| { println!("{} ready with {}", event.worker_id, event.command_count);});Runtime-constructed events: DynEvent
Section titled “Runtime-constructed events: DynEvent”When the id or payload shape is only known at runtime, build a DynEvent:
use serde_json::json;
registry.emit(DynEvent::new("plugin.foo", json!({ "ok": true })))?;
let _unsub = registry.on_dyn("plugin.foo", |payload| { println!("got dynamic event: {payload}");});Private events (id starts with _) fire only to local listeners — never broadcast across channels. Same rule as for commands.
CommandChannel trait
Section titled “CommandChannel trait”#[async_trait]pub trait CommandChannel: Send + Sync { fn id(&self) -> &str; async fn send(&self, message: Message) -> Result<(), ChannelError>; fn subscribe(&self) -> Receiver<Message>; async fn close(&self) -> Result<(), ChannelError>;}Built-in implementation:
InMemoryChannel—InMemoryChannel::pair(local_id, peer_id)returns twoArc<InMemoryChannel>halves wired together. Ideal for same-process tests and single-process multi-registry setups. See InMemoryChannel.
Implement the trait for custom transports (WebSocket, gRPC, stdio, etc.). The wire Message type is byte-identical to the TypeScript union — see Protocol.
Macros
Section titled “Macros”Re-exported from coralstack-cmd-ipc-macros. See Defining Commands for full usage.
#[command("id", description = "...")]— attribute on a freeasync fnor animplmethod. For a free fn namedgreet, also emits aregister_greet(®istry).await?helper and aGreetCommandtype usable with strictexecute::<GreetCommand>(..).#[command_service]— attribute on animpl Hostblock. Generates:Host.register(®istry).await?— installs every tagged method.- A nested
pub(super) mod host_snake_case_namecontaining one wrapper type per method, reachable for strict execute:registry.execute::<host_name::MethodName>(req).await?.
#[event("id")]— attribute on a payload struct. Auto-derivesSerialize/Deserialize/JsonSchemaand emits animpl Event for Structwith id and schemars-derived schema. The struct is then usable asregistry.emit(Struct { .. })andregistry.on::<Struct>(cb). Unit structs (struct Foo;) produce void events (no payload on the wire).#[payload]— attribute on any struct (typically command request/response types). Auto-derivesSerialize/Deserialize/JsonSchemaagainst theserde/schemarsre-exports fromcoralstack-cmd-ipc, so user crates don’t need to add those dependencies themselves. Additive — extra derives (Clone,Debug, …) can be stacked on top normally.
Errors
Section titled “Errors”| Type | Purpose |
|---|---|
CommandError | Returned from command handlers; serialized as ExecuteError on the wire |
ChannelError | Transport-level failures |
ExecuteErrorCode | Enum of wire-level error codes (CommandNotFound, ValidationError, etc.) |
RegisterErrorCode | Enum of registration error codes (CommandAlreadyRegistered) |
MCP adapter
Section titled “MCP adapter”coralstack-cmd-ipc-mcp::McpServerChannel implements CommandChannel, so it registers on the registry like any other channel. It wraps an rmcp server handler internally.
use std::sync::Arc;use coralstack_cmd_ipc_mcp::McpServerChannel;
let mcp = Arc::new(McpServerChannel::new("mcp"));let driver = registry.register_channel(mcp.clone()).await?;tokio::spawn(driver);
mcp.serve_stdio().await?;Pure translation between MCP and cmd-ipc wire messages — no registry reference held. MCP tools/list / tools/call emit ListCommandsRequest / ExecuteCommandRequest through recv(); the registry’s ExecuteCommandResponse comes back via send() and is correlated to the waiting MCP call by thid.
| Method | Purpose |
|---|---|
new(id) | Create the channel with a given id |
with_implementation(name, version) | Override the serverInfo reported to MCP clients |
with_instructions(s) | Attach an instructions string for MCP clients |
with_timeout(Duration) | Timeout for MCP-originated requests awaiting the registry |
serve(transport) | Drive the MCP protocol over any rmcp transport (stdio, TCP, duplex, WebSocket, …) |
serve_stdio() | Convenience wrapper: serve(rmcp::transport::io::stdio()) |
into_handler() | Returns an rmcp ServerHandler — use this to plug into HTTP frameworks (axum, actix, warp, …) as a per-session handler factory |
Stdio ships in the crate by default. For HTTP or any other transport, enable the relevant rmcp feature in your own Cargo.toml and pass the transport to serve() (or use into_handler() for HTTP’s per-session model).
Private commands (prefixed with _) are never exposed as tools.