Skip to content

Architecture

MCP client (Claude Code, Cursor, …)
│ stdio: JSON-RPC over stdin/stdout
▼
mero-mcp
├── core tools ─┐
├── app tools ─┤ registered on an McpServer
└── generated tools ┘
│ mero-js: HTTP admin API + JSON-RPC
▼
Calimero node (merod / desktop app)

One server process drives one node. There is no HTTP listener, no SSE, and no session multiplexing: the client owns the process lifetime, and stdout is the protocol channel — which is why every diagnostic goes to stderr.

  • Directorysrc/
    • index.ts entrypoint: serveStdio over the server factory
    • server.ts the server factory: catalog, tools, guide resources, cache hints
    • catalog.ts installed apps, refreshed on install and every 30 s
    • handle.ts HMAC app handles
    • gate.ts handle checks and refusals
    • guide.ts guide parsing and delivery
    • config.ts env parsing, node discovery, the handoff file
    • node.ts session creation, auth mode, the token store
    • session.ts the lazy session proxy
    • abi.ts application resolution and the ABI loader/cache
    • schema.ts ABI types → zod schemas → JSON Schema
    • errors.ts node/guest error decoding, MCP result envelopes
    • Directorytools/
      • core.ts node, context, namespace and blob tools
      • app.ts describe/select/call
      • generated.ts one tool per installed app method

The process starts without contacting the node. serveStdio reads the client’s opening message, picks the 2025-11-25 or 2026-07-28 protocol era from it, and builds one server from the factory. The factory makes one attempt to read the installed apps, which is the first request to the node.

A node that is down does not stop the server: it leaves only the fixed tools, and the error goes to stderr. The first refresh that reaches the node adds the app tools and sends a list-changed notification. The 30-second poll starts with the first read that reaches the node. When the node was down at connect, that read is the first select_app, describe_app or call for an app the list lacks, or mero-mcp’s own install_application or uninstall_application.

Behind every node request sits a lazy session:

  • The first request triggers the real connection.
  • On success, the session is memoised for the process lifetime.
  • On failure, only that request fails and the memo is cleared, so the next one tries again. Starting the node afterwards needs no restart of the server.

The lazy session is a Proxy over the admin and rpc namespaces: any method call on it awaits the real session, then forwards to the same-named method. Until the first call resolves, url, nodeName and authMode hold placeholders, which is why node_status reads them after forcing a connection.

createSession does three things in order:

  1. resolveNode — walk the discovery rungs until one matches (node discovery).
  2. pickAuthMode — handoff, then token, then credentials, then none (authentication).
  3. Build a MeroJs client with a per-node, per-identity FileTokenStore.

How a node was found and what it is called are kept separate: resolveNode labels a node by its source (handoff, discovered), while the name reported to you comes only from its directory under CALIMERO_NODE_HOME or from CALIMERO_NODE_NAME. A node nothing names reports nodeName: null rather than inventing one.

The catalog, describe_app, select_app, call and every generated tool go through one loader, which does two jobs.

Resolve the application. An id, a full package name, or an unambiguous trailing package segment all resolve to one installed application — or to an error that names the candidates (ambiguous) or the installed set (no match). Matching on the short form is whole-segment only, so mero-chat cannot resolve to mero-chat-v2. A display name (the bundle’s metadata.name, in any case) is matched together with the trailing segment, so a name that is one app’s segment and another’s display name is ambiguous.

Fetch and cache the manifest. The cache key is <blobId>:<serviceName> — the application’s bytecode blob id, not its application id. Blob ids are content-addressed, so an upgraded application resolves to a new key and gets a fresh ABI automatically; there is no staleness to invalidate.

The manifest is only cached after it parses, so one malformed response cannot wedge an application for the rest of the session.

The catalog is the list of installed apps every tool list is built from, one entry per app and service. An app is a package from one signer: core derives the application id from both, so an upgrade replaces the app in place and a node never holds two versions of it. It is server-global, so no list varies by connection, and it is sorted by package, then application id, then service. It changes only when its fingerprint of (application id, blob id, version, service, guide hash) changes, which re-registers the generated tools and sends debounced list-changed notifications for tools and resources. An app whose ABI cannot be read is left out, with the reason on stderr.

Slugs are derived from the resolved application rather than the string you typed, so one application always names its tools the same way. On a collision (two packages sanitising to one slug, or one package from two signers) the suffix comes from the application id, never from list order, keeping names stable.

An app_handle is base64url(payload) + "." + base64url(HMAC-SHA256(key, payload)), with the payload:

Key Meaning
a application id, which binds the package and its signer
p package
v app version
g first 16 hex of sha256 of the guide, or of "" when there is none
c context id, or null for a describe_app handle
s service name, or null

The key is 32 random bytes kept in handle.key in the state directory, owner-only beside the token files, so handles survive a server restart. Deleting the file rotates the key, and every handle issued before goes stale. A handle is checked against the app as installed at call time, not against the catalog the tool was built from, so an upgrade makes old handles stale at once. The checks run in order: the app as installed now, then the handle, then the arguments, then execution. A missing handle therefore gets the refusal with the guide even when the arguments are wrong too.

For a generated tool:

  1. Check the app_handle against the app as installed now; refuse with the guide if it does not match.
  2. Validate the arguments: only declared ABI parameters are forwarded, so the app_handle never reaches the application.
  3. rpc.execute({ contextId, method, argsJson }), with the context from the handle.
  4. Fold the result into the JSON text plus, when the method returns something, structuredContent; or fold the throw into isError: true.

Read-only ABI methods carry readOnlyHint; core tools and ABI methods the author marked destructive carry destructiveHint, so clients that gate on those annotations can.

mero-js surfaces neither guest errors nor node error bodies usefully — they arrive as a type name or a bare status line. errors.ts recovers the real message:

  • Guest errors. A WASM app error has no top-level message, only type: "FunctionCallError" and data. Core emits that data in two shapes: a Rust-debug-formatted byte array of the error text’s UTF-8, or a plain string (a panic message). Both are decoded, including unwrapping the JSON quoting the guest adds.
  • HTTP errors. HTTPError.message is only HTTP <status> <statusText>, so the explanation has to come from the body — core answers every handled failure with {"error": "…"}. With no body, the endpoint path is named so the status at least says what failed. Status 0 is reported as “cannot reach the node”, since that is this process failing rather than the node rejecting anything.

Query strings are stripped from any URL that appears in an error, because they can carry a credential.