Skip to content

ABI mapping

An application’s ABI manifest is turned into a zod schema per method, which the MCP SDK advertises as JSON Schema. That schema is what stops an agent guessing at argument shapes - a wrong type fails locally, before anything reaches the node. The author’s /// doc comments, when the ABI carries them, become the tool, parameter, type and output descriptions.

Generated tools are named:

<app>_<method> single-service application
<app>_<service>_<method> multi-service application

<app> is the trailing dot-separated segment of the package name, lowercased with every run of non-[a-z0-9_] characters collapsed to _. So com.calimero.kv-store gives kv_store, and its get method gives kv_store_get.

The slug, including a multi-service app’s _<service>, is cut to 20 characters. A full name longer than 49 characters (64 minus the 15-character mcp__mero-mcp__ prefix Claude Code adds) keeps its first 42 characters and ends in _ plus the first 6 hex of its sha256.

The slug is derived from the resolved application, not the string you typed, so one application always names its tools the same way. If two apps sanitise to the same slug, the second in package order gets _ plus the first 6 alphanumerics of its application id, so names are stable across sessions. The same applies to one package from two signers, which are two apps: the one later in application id order takes the suffix. A whole name is also kept unique: when a slug plus a method spells a fixed tool (com.x.node with status would be node_status) or another app’s tool, that tool takes the application-id suffix too.

ABI kind JSON Schema
bool boolean
string string
unit null
i32, i64 integer
u32, u64 integer, >= 0
f32, f64 number
ABI kind JSON Schema
list array of the item schema
map object with string keys and the value schema
tuple fixed-length array, positionally typed
record object with one property per field

A field marked nullable in the ABI becomes nullable in the schema. For method parameters, nullable also means optional — a nullable parameter can be omitted entirely, not just passed as null.

bytes accepts either form:

  • a hex string — "deadbeef" — which is the only string form the Calimero toolchain reads, or
  • an array of integers 0–255, which is what the node itself wants.

Hex input is transformed to a byte array before it goes out. Fixed-size bytes carry their length in both forms: the array is length-constrained, and the hex pattern requires exactly 2 × size hex characters — so the size travels in the advertised JSON Schema rather than living only in the validator.

Each bytes field is described as bytes: a hex string or a 32-byte array, so an agent reading the schema knows both are accepted.

Rust enums arrive as variant definitions. With no tagging keys they map to serde’s default externally tagged representation:

  • Unit variants ride as bare names — "Pending".
  • Payload variants ride as a single-key object — {"Approved": {"by": "…"}}.

A variant with no payloads anywhere becomes a plain string enum, unless a unit variant carries a doc, in which case it becomes a oneOf of { const, description } entries instead (see Docs and hints). A mix becomes a oneOf of the unit variants (as an enum or as documented consts) and one object schema per payload variant.

An enum with another serde representation carries tag, content or untagged on its variant definition, and its schema follows that wire shape:

ABI keys serde attribute Unit variant Payload variant JSON Schema
none default "Name" {"Name": payload} enum or oneOf, as above
tag #[serde(tag = "kind")] {"kind": "Name"} {"kind": "Name", ...payload fields} oneOf
tag + content #[serde(tag = "t", content = "c")] {"t": "Name"} {"t": "Name", "c": payload} oneOf
untagged #[serde(untagged)] null the bare payload anyOf

An enum with a single variant is that variant’s schema, not a union. An internally tagged payload is one object holding the tag and the payload’s fields, found through a newtype struct too. A map payload keeps its entries beside the tag, each checked against the map’s value type. A payload that is itself an internally or adjacently tagged enum adds its own tag and payload beside the outer tag. Any other payload, including an enum reusing the outer tag’s name, only requires the tag and leaves the rest to the node. A tagged value is matched by its tag, so a refusal names the problem of the variant the tag chose. Untagged members can overlap, so they are advertised as anyOf, and a value is matched against them in order, the first match winning, as serde does. Bytes inside an untagged member take no hex string form, because serde would read that string as a later string member instead. A value in another representation, such as {"Text": {...}} where the ABI says tag, is refused before the node is called.

A record carrying both crdt_type and inner_type is a transparent wrapper: the wire value is the inner type, not the wrapper’s fields. The schema unwraps it, and the rendered signature unwraps it the same way — the signature an agent reads has to match what the tool actually accepts.

Named ABI types are advertised once under $defs and referenced with $ref, so a recursive type is described exactly rather than degrading to unknown. A $ref naming a built-in absent from the type table degrades to unknown rather than failing the whole application.

unknown means the argument is accepted unvalidated and passed through — the call can still succeed, it just is not checked locally.

Every generated tool takes one required argument besides the method’s own:

app_handle: string The app_handle select_app returned for this application; it names the context the call runs in.

The context a call runs in comes from the handle, so there is no separate context argument. The handle is the only argument mero-mcp adds, so an application’s own context parameter is an ordinary parameter. A method that declares its own parameter named app_handle would collide with it, so it has no generated tool and is reached with call, whose args object keeps the two apart. Outgoing arguments are built by filtering the input down to the method’s declared parameters, so the handle never reaches the application.

describe_app and select_app render each method as a signature, which is a documented method’s tool description too, below its doc (see Docs and hints):

[view] get(key: string) -> string | null
[mut] set(key: string, value: string) -> unit
[view] entries() -> map<string, string>
  • [view] — the ABI marks it read_only; the tool carries readOnlyHint.
  • [mut] — everything else.
  • ? after a parameter name means nullable/optional.
  • | null after the return type means the return is nullable.
  • A missing return type renders as unit.

Type names in signatures follow the same rules as the schemas: T[] for lists, map<K, V> for maps, (A, B) for tuples, $ref names for referenced types, and CRDT wrappers unwrapped to their inner type.

A method that returns something also gets an outputSchema derived from its return type, where bytes is a number array, since that is what the node returns. For 2025-11-25 clients the SDK wraps a return that is not an object as { result }; 2026-07-28 clients get the return schema itself.

An app’s /// doc comments travel in its ABI as optional doc fields. A documented method’s tool description is the doc, a blank line, then the signature:

Apply a batch of block edits.
# Errors
Fails past 512 edits.
[mut] set_blocks(edits: Edit[], now: u64) -> u32

A documented parameter or record field carries its doc as the JSON Schema description; on a bytes value the doc comes first and the bytes hint follows in parentheses. A documented named type carries its doc on its $defs entry, and documented enum variants become oneOf entries of { const, description }. A method’s # Returns section becomes the outputSchema description. #[app::destructive] and #[app::idempotent] set destructiveHint and idempotentHint; a read-only method is always idempotent. describe_app lists each method’s description with one indented name: doc line per documented parameter.

With no docs in the ABI (an app built before docs existed, or a merod that drops them), descriptions are the bare signatures and parameters carry no description.