Skip to content

Advanced Rust Plugins

Start with the single-file Rust plugin guide for actions, data sources, connectors, and inline dependencies. This page covers Cargo packaging, the full SDK, deployment, and detailed single-file behavior.

Single files cover every plugin feature a Cargo plugin has: actions, source and instance connectors, sync hooks, setup, plugin command-line flags, a version, required packages, HTTP routes, MCP tools, resources and prompts, console pages, job executors, and streaming connectors. Use a Cargo plugin for multiple source files, typed Arrow data, or full control of the manifest and the ABI. Rust plugins support these native extensions; Python Dash callbacks and arbitrary FastAPI customization remain Python-host capabilities.

Only build and load plugins you trust: native plugins run inside the Meerschaum process.

πŸ“ Layout

A native plugin is a directory in MRSM_PLUGINS_DIR (the existing multi-directory path list works too) holding a descriptor and the library:

plugins/
└── native_demo/
    β”œβ”€β”€ plugin.toml
    └── libnative_demo.so       # .dylib on macOS; install as libnative_demo.dll on Windows

plugin.toml has two required keys:

name = "native_demo"
version = "1.0.0"

The library name is always lib<name>.<ext>, with the lib prefix on Windows too. Names may contain letters, digits, _ and -. Directories are scanned in order, earlier MRSM_PLUGINS_DIR entries win, and a plugin that is invalid (bad descriptor, missing library, wrong ABI) is skipped with a warning while the others still load.

Rust before Python

A directory that contains both a plugin.toml and Python files is a Rust plugin. If a Rust plugin and a Python plugin have the same name, the Rust plugin replaces the Python plugin entirely: its actions, hooks, connectors, routes and pages disappear from the merged manifest, and nothing falls back to the Python module, even for functions the library does not implement. Python plugins with other names are unaffected and keep working beside the native one. To port a plugin gradually, split it: move one capability into a Rust plugin with a new name and keep the rest in Python (the two may depend on each other through required).

Gradual migration

Keep a Python plugin such as acme_ui for @dash_plugin, custom login layouts, and FastAPI handlers, and put the data capability into a differently named Rust plugin, acme_data. For example, the native plugin can implement fetch and the existing Python page can read its registered Pipe through the normal SDK:

import meerschaum as mrsm

readings = mrsm.Pipe('plugin:acme_data', 'readings', instance='sql:local')
# The page continues to use its existing Dash callbacks and data formatting.
data = readings.get_data()

Use the native fetch example below as the starting implementation for acme_data. Register and sync that Pipe before opening the page. Moving the fetch implementation does not require rewriting the page or changing its session handling.

For a fetch plugin, first list the operations it performs on its Python pipe argument and on other pipes and connectors. Each has a host call: pipe.get_data(), pipe.get_sync_time(), pipe.sync() (pipe.replace() for replace=True) and pipe.clear() on the Pipe being synced or on any mrsm.Pipe(...) (host::pipe(...)), conn.read() and conn.exec() on a SQL connector (host::read_sql, host::exec_sql), and get_plugin_config() (host::plugin_config()). Cargo plugins get the same view with request.pipe()?. Importing helpers from Python plugins (from_plugin_import) has no Rust counterpart: port the helper too. Listing a Python plugin in required does not make its functions callable from Rust.

Validate a port under a separate plugin name and scratch root before changing existing Pipe connector keys. Compare its rows, nulls, dtypes, bound handling, empty responses, and errors with the Python implementation using recorded or synthetic inputs. Then sync the native plugin through mrsm into a disposable instance to check discovery, the Arrow boundary, and repeated-sync behavior together.

Native routes take path parameters (Starlette syntax, converters included), any method, required scopes, the caller's principal, request and response headers (cookies and redirects included) and byte bodies, and serve requests concurrently; see Routes and MCP. They are not FastAPI: there is no dependency injection, OpenAPI entry or request validation beyond what your handler does. Same-name native plugins replace the complete Python plugin rather than individual capabilities.

πŸ”§ Writing one

Create a library crate:

# Cargo.toml
[package]
name = "native_demo"
version = "1.0.0"
edition = "2024"

[lib]
crate-type = ["cdylib"]

[dependencies]
meerschaum-plugin = "5.0"

[profile.release]
panic = "unwind"      # required: the SDK catches panics at the boundary

Implement the Plugin trait and export it once:

use meerschaum_plugin::prelude::*;

#[derive(Default)]
struct Demo;

impl Plugin for Demo {
    /// Declares what this plugin provides (see "The manifest" below). The plugin's name and
    /// version are the package's, from Cargo.toml.
    fn manifest(&self) -> Value {
        Manifest::new()
            .fetch()
            .action("hello", "Return a synthetic greeting.")
            .into()
    }

    /// `fetch`: return the new rows as an Arrow-backed `Frame`.
    fn fetch(&self, request: &Request) -> Result<Option<Frame>> {
        let rows = vec![
            json!({"ts": "2024-01-01T00:00:00Z", "id": 1, "value": 1.5}),
            json!({"ts": "2024-01-01T01:00:00Z", "id": 2, "value": 2.5}),
        ];
        Frame::from_rows(rows, &[("ts", "datetime"), ("id", "int"), ("value", "float")]).map(Some)
    }

    /// Everything else (actions, hooks, routes, pages, instance methods) arrives here.
    fn call(&self, request: &Request, data: Option<Frame>) -> Result<Reply> {
        match request.method.as_str() {
            "fetch" => self.fetch(request).map(Reply::data),
            "action" if request.context["name"] == "hello" => {
                Ok(Reply::json(json!([true, "Hello from native_demo"])))
            }
            _ => Err(Error::Unsupported(format!("{} is not implemented", request.method))),
        }
    }
}

meerschaum_plugin::export_plugin!(Demo);

Frame::from_rows(rows, dtypes) turns JSON objects into a typed Arrow frame with Meerschaum dtypes (datetime, date, int, float, string, bool, json, numeric, uuid, bytes, geometry[...], sized integers such as int32, or a pandas dtype): ISO timestamps become UTC datetimes, a declared column no row has is added as nulls, and undeclared columns are inferred like a Python plugin's documents. frame.to_rows() goes back to JSON objects. You can still build a Frame from Arrow RecordBatches (Frame::from_record_batch), and the SDK re-exports arrow, chrono and serde_json, so a plugin needs no other Meerschaum crate.

The plugin's name and version come from the package (CARGO_PKG_NAME, CARGO_PKG_VERSION, read by export_plugin!); they must match plugin.toml. Set const NAME or const VERSION in the impl to use others. The host calls (host::pipe(...), request.pipe()?.get_data(...), host::read_sql(...)) are in Talking to Meerschaum.

The complete, tested version is crates/meerschaum-plugin/examples/native_demo.rs. It is a source connector, an in-memory instance, an action with an argument, sync hooks, two routes and a console page, all on synthetic data; its manifest is a whole JSON document, which is still accepted. native_host.rs is written the new way (a Manifest, the version from Cargo, Frame::from_rows) and makes host calls: it reads another pipe, counts its rows with SQL, rewrites a window and keeps its settings in the plugin config.

Things to know:

  • Stateless handlers. The SDK builds Default::default() for every call. Keep shared state in a synchronized static (a Mutex) or a database, keyed by connector label.
  • Synchronous calls. The SDK imposes no async runtime. Calls into one loaded plugin are serialized by the host, except stream calls (next_batch, ...) and the server calls (api_route, mcp_tool, mcp_resource, mcp_prompt), which run concurrently and must be thread-safe (see Routes and MCP). Schedule blocking work on your own executor if you need one.
  • Panics are contained. Constructors and calls run inside catch_unwind and surface as an error (native plugin panicked). An aborting panic, process::exit or memory fault cannot be recovered. Build with panic = "unwind".
  • Same OS and architecture. The host and the plugin do not need the same Rust compiler version, since only extern "C" and the Arrow C interfaces cross the boundary, but they must target the same OS and CPU.

What Request contains

Field Meaning
method The operation: fetch, sync, action, pre_sync_hook, post_sync_hook, api_route, mcp_tool, mcp_resource, mcp_prompt, routes, route, page, register_pipe, get_pipe_data, and so on
connector_type, label, config Which connector instance the call is for, and its configuration
context A JSON object. For pipe operations, {"pipe": {"keys": ..., "parameters": ..., "temporary": ...}}; reads add query, syncs add options; actions use {"name": ..., "kwargs": ...}, and hooks {"name": ..., "kwargs": ..., "pipe": ...}

Bounds (begin, end) are integers, ISO timestamp strings or null. Results are plain JSON: success tuples as [true, "message"], missing values as null.

πŸ“œ The manifest

manifest() declares what your plugin provides, so --help, tab completion and the server learn it without loading the library (it is cached by a fingerprint of the plugin directory and rebuilt when a file changes). Build it with Manifest:

fn manifest(&self) -> Value {
    Manifest::new()
        .fetch()                                    // data functions: fetch, sync, register, setup, subscribe
        .action("hello", "Return a greeting.")      // `mrsm hello`
        .flag("--loud", "Shout.")                   // or .argument(json!({...})) for any argparse option
        .pre_sync_hook("before")
        .post_sync_hook("after")
        .connector(ConnectorType::new("memory", ["fetch", "register_pipe", "sync_pipe"]).instance())
        .executor_type("procs", ["get_job_names", "create_job"], "Run jobs as local processes.")
        .api_route(RouteSpec::new("item", "GET", "/my/items/{id}").scopes(["pipes:read"]))
        .mcp_tool(ToolSpec::new("add", "Add two integers."))
        .web_page("my/page", "page", true)          // /dash/my/page, login required
        .required(["plugin:noaa"])
        .into()
}

The SDK completes the document: the plugin's name and version (the package's), schema_version (meerschaum_plugin::MANIFEST_SCHEMA_VERSION; native plugins and Python workers share manifest schema 5), meerschaum_version (the SDK's), python_version (""), fingerprint (null), each connector or executor type's plugin, and the top-level pre_sync_hooks / post_sync_hooks. Manifest::set(key, value) sets any other key of the plugin's entry. A whole JSON document (the format of earlier releases) is still accepted: the SDK fills in only what it lacks. The host validates the result against the bundled JSON schema (crates/meerschaum-plugin/schema/manifest.schema.json) at load time.

Native libraries built with a 5.0 pre-release SDK (manifest schema 3) must be rebuilt with the current SDK. Stale manifest caches are regenerated automatically. The native ABI and worker protocol remain at version 1.

In the JSON document, the part for your plugin lives under plugins.<name>:

{
  "name": "native_demo",
  "version": "1.0.0",
  "data_functions": ["fetch", "sync"],
  "actions": [{"name": "hello", "doc": "Return a synthetic greeting."}],
  "arguments": [{"option_strings": ["--native-demo"], "action": "store_true", "help": "Enable demo."}],
  "pre_sync_hooks": ["before"],
  "post_sync_hooks": ["after"],
  "connectors": ["native_demo"],
  "api_plugins": ["routes"],
  "web_pages": [{"path": "/dash/native-demo", "page_group": "Native demo",
                 "page_key": "native-demo", "login_required": true,
                 "function": "page", "mode": "html"}]
}

Arguments reach context.kwargs exactly as they reach a Python plugin's **kwargs, which is v4's rule: an argument that was not given is absent (never false or null), a flag that was given is true, and a store_false flag is present (as true) only while it is not given. Declare "default": "None" to receive an explicit null. Read flags with a fallback (kwargs.get("native_demo").and_then(Value::as_bool).unwrap_or(false)).

Equivalents of @make_action and @make_connector

Single-file plugins use #[make_action] on a function and #[make_connector] on a module, as shown in the single-file guide, #[pre_sync_hook], #[post_sync_hook] and #[add_plugin_argument(...)] for hooks and flags, #[api_route], #[mcp_tool], #[mcp_resource] and #[mcp_prompt] for server extensions, #[make_connector(instance)] for instances, #[web_page] for console pages, #[make_executor] for executors, and fn subscribe for streams (see the single-file reference). The generator writes their manifests and dispatchers for you.

For packaged Cargo plugins, use declarations in manifest() and handlers in Plugin::call(), as described here.

For Python's @make_action, declare the action ("actions": [{"name": "hello", "doc": ...}] in the JSON document):

Manifest::new().action("hello", "Return a greeting.")

Then handle the action in call():

"action" if request.context["name"] == "hello" => {
    Ok(Reply::json(json!([true, "Hello!"])))
}

This registers mrsm hello; parsed options arrive in request.context["kwargs"].

For Python's @make_connector, declare the connector type and the methods it answers:

Manifest::new().connector_type("my_source", ["fetch", "register"])

In the JSON document that is "connectors": ["my_source"] in the plugin's entry and "connector_types": {"my_source": {"type": "my_source", "plugin": "native_demo", "class_name": "Demo", "is_instance": false, "is_thread_safe": false, "methods": ["fetch", "register"]}} at the top level. This provides my_source:<label> connectors. Dispatch by request.method; the connector type, label and configuration are in request.connector_type, request.label and request.config. Use ConnectorType::new(...).instance() (is_instance: true) and implement instance operations when the connector should also be usable with -i.

The optional source register handler returns a JSON object of default Pipe parameters (or null), with the Pipe in request.context["pipe"]. For plugin:<name>, also list register under data_functions. Bootstrap uses these defaults; ordinary registration merges explicitly supplied parameters over them. This source hook differs from register_pipe, which persists a Pipe on an instance.

Rust plugins cover actions, arguments, hooks, fetch/sync, custom connectors and instances, supported HTTP routes, and console page fragments, and they reach pipes, SQL connectors and their config through host calls. They do not have complete Python plugin parity: importing Python plugin helpers (from_plugin_import), Dash callbacks, unrestricted FastAPI customization and live library reload are not available. The Pipe helper surface is described in the single-file reference, and HTTP adapter limits are described above; see the status note below for further limits.

🧩 What a plugin can provide

You want Do this
A fetch source for plugin:<name> pipes Implement fetch, list fetch under data_functions. The engine passes begin, end, params and the pipe's keys and parameters in context.
A sync override Implement sync; it receives optional data and returns a success tuple, like a Python sync(pipe, **kw).
An action (mrsm hello) Add it to actions and answer the action method (context.name, context.kwargs). Add flags with arguments.
Hooks List names under pre_sync_hooks / post_sync_hooks; they receive the same kwargs as Python's @pre_sync_hook and @post_sync_hook (sync_timestamp, plus success_tuple, sync_duration and sync_complete_timestamp after the sync, ...), and the synced Pipe in context.pipe (Python passes it positionally, so it is not among the kwargs).
A whole connector or instance List it under connectors / connector_types (set is_instance), and answer the instance methods (register_pipe, sync_pipe, get_pipe_data, fetch_pipes_keys, ...). A method you do not implement returns Error::Unsupported, which the host maps to "not implemented" and lets the engine fall back (for example to file-based leases).
HTTP routes Declare api_routes ({name, method, path, scopes?, public?}, with {param} path segments) and implement api_route, which receives the whole request (path and query parameters, headers, body bytes, principal, scopes) and returns a status, headers and body bytes. Routes require the caller's credentials and declared scopes unless "public": true or the server runs with --no-auth. A route that would shadow a core route is refused at startup. See Routes and MCP.
MCP tools, resources, prompts Declare mcp_tools, mcp_resources, mcp_prompts and implement mcp_tool, mcp_resource, mcp_prompt. They are served by /mcp and mrsm start mcp with the same scope and read-only filtering as built-in tools; tool arguments are validated against your input schema. See Routes and MCP.
A job executor (-e <type>:<label>) Declare it under the top-level executor_types and return an Executor from Plugin::executor; methods you leave out answer "not supported". See Executors and Streams.
A streaming connector (sync pipes --listen) Mark the connector type is_streaming with subscribe in its methods (or list subscribe under data_functions) and return a Subscription from Plugin::subscribe; the host pulls Arrow batches with next_batch and acknowledges them after they commit. See Executors and Streams.
A console page List it under web_pages; answer page with {html, status?}. The Python web console (meerschaum[dash]) fetches your fragment from the server (/_mrsm/native-pages) and frames it in its chrome; a standalone install without the console does not show it. See the page contract.

Data always crosses the boundary as an Arrow C stream (FFI_ArrowArrayStream), never JSON. The SDK handles that for you: you return a Frame, and the host imports it without copying through Python.

Hooks run around every sync that goes through the sync engine (sync pipes, jobs, compose). As with Python hooks, sync pipes prints every hook's success tuple (successful or not) in order, and a failing hook never fails the sync. A hook that returns an error (instead of a tuple) is reported as Failed to execute sync hook '<hook>' from plugin '<plugin>': followed by the error, both as a warning and as a failed tuple, like a Python hook that raised; the remaining hooks still run. Native hooks run before Python hooks.

πŸ—οΈ Build, install, load

Build the shipped example from a checkout (the prebuilt route, which needs no toolchain at runtime):

cargo build --release -p meerschaum-plugin --example native_demo

Install it into a plugins directory:

mkdir -p "$MRSM_PLUGINS_DIR/native_demo"
cp crates/meerschaum-plugin/examples/plugin.toml "$MRSM_PLUGINS_DIR/native_demo/"
cp target/release/examples/libnative_demo.so "$MRSM_PLUGINS_DIR/native_demo/"

Source plugins

You do not have to build and copy the library yourself. A plugin directory that is a Cargo crate is built by Meerschaum when cargo is on PATH:

plugins/
└── native_demo/
    β”œβ”€β”€ plugin.toml
    β”œβ”€β”€ Cargo.toml              # [package] name = "native_demo", [lib] crate-type = ["cdylib"]
    β”œβ”€β”€ Cargo.lock              # optional, recommended
    └── src/lib.rs

A directory counts as a source plugin when it has a plugin.toml and a Cargo.toml and no up-to-date lib<name>.<ext> next to plugin.toml. (A library shipped next to the sources is used as is, unless a source file is newer than it.) The crate's [package] name must equal the name in plugin.toml and the crate must build a cdylib with panic = "unwind"; otherwise the plugin is skipped with a warning like any other invalid plugin.

When it builds. The first time mrsm discovers the plugin, and again whenever its source fingerprint changes: a hash of the contents of every file in the plugin directory except target/, hidden directories (but .cargo/ is included) and shared libraries, so assets read by include_str! and files read by build.rs count, as do Cargo.toml, Cargo.lock and plugin.toml. Meerschaum runs cargo build --release (the CARGO environment variable names the executable if it is set) with --manifest-path, --target-dir and CARGO_INCREMENTAL=0, prints one line on stderr (Building native plugin 'native_demo' (cargo build --release)...) and is silent afterwards. The first build compiles the SDK's dependencies and takes minutes. A build whose sources have not changed is never repeated, and --help and tab completion read the cached manifest without building. Path dependencies outside the plugin directory are not part of the fingerprint, and the hash follows file contents, not modification times (touching a file does nothing): after changing such a dependency, edit a file in the plugin directory or delete <root>/cache/native_build/<name> to force a rebuild.

Trust: building runs code

cargo build runs the crate's build scripts and procedural macros and may download dependencies from crates.io, with no timeout. It runs whenever plugins are opened and a build is needed, including mrsm start api, and it is the same trust as loading the library: only put crates you trust in MRSM_PLUGINS_DIR. A build failure, including a transient one such as being offline, is remembered until the sources change or <root>/cache/native_build/<name> is deleted.

Where the artifact lives. Under <root>/cache/native_build/<name>/ (the root is MRSM_ROOT_DIR); the library is loaded from <root>/cache/native_build/<name>/release/lib<name>.<ext> and its path is recorded in the plugin's entry of <root>/cache/native_plugins_manifest.json. The only file Cargo may write into the plugin directory is Cargo.lock, when it is missing or out of date, so ship an up-to-date Cargo.lock and a read-only plugins directory works. Delete the native_build directory to force a rebuild.

No toolchain. Without cargo the plugin is skipped and the rest still load:

Warning: Skipping native plugin at '/plugins/native_demo': it is a source crate and no `cargo` was found on PATH (install a Rust toolchain or ship libnative_demo.so)

A failed build prints the last lines of cargo's stderr in the same kind of warning and skips that plugin only. The failure is remembered: later runs skip the plugin with the last build failed; edit the sources or delete '<build dir>' to retry and do not run cargo again until you edit the sources or delete the build directory. If a library is shipped next to the sources but is older than them and there is no cargo, the shipped library is loaded with a warning that it was not rebuilt.

The slim image ships no toolchain

bmeares/meerschaum:slim has no Rust toolchain, so there only the prebuilt contract works: put plugin.toml and lib<name>.so in the plugin directory, built for the image's OS and architecture.

Point MRSM_PLUGINS_DIR at the directory (use a temporary MRSM_ROOT_DIR while experimenting). The action and its argument are discovered immediately:

$ mrsm hello
 πŸŽ‰ Hello from native_demo

$ mrsm --help | grep '^  hello'
  hello        Return a synthetic greeting.

mrsm --help lists the plugin's actions in its Plugins group (with the first line of each docstring) and the plugin's flags (--native-demo) under Plugin 'native_demo' options; mrsm hello -h prints the whole docstring, and mrsm hello --native-demo is accepted.

Register a pipe that uses it as a source:

mrsm register pipe -c plugin:native_demo -m demo -i sql:local --params '{"columns": {"id": "id"}}'
mrsm sync pipes -i sql:local --nopretty
{"pipe": {"connector_keys": "plugin:native_demo", "metric_key": "demo", ...}, "success": true, "message": "Success"}

The example declares both fetch and sync, so (exactly like a Python plugin that defines sync()) its own sync replaces the built-in sync process: it keeps the rows in memory and reports Success instead of writing a table. Remove sync from data_functions to let Meerschaum write the fetched frame to your instance.

πŸ§ͺ Testing your plugin

Everything below runs against a throwaway root, so it never touches your real configuration:

export MRSM_ROOT_DIR="$(mktemp -d)"
export MRSM_PLUGINS_DIR="$(mktemp -d)"      # install your plugin directory here
mrsm show plugins                           # native plugins are listed as `name (native)`
mrsm show plugins --nopretty                # bare names, for scripts
mrsm show actions --nopretty                # your actions appear here
mrsm --help                                 # ...and here (the Plugins group), with their flags

A plugin that is missing from these listings was skipped: look for Warning: Skipping invalid native plugin at '<dir>': <reason> on stderr. The reasons are a bad plugin.toml, a missing or unloadable library, an incompatible ABI (for example unsupported native plugin ABI 99; expected 1), and, for a source crate, a package name that differs from plugin.toml or a crate that is not a cdylib. A valid source crate that could not be built (no cargo, a compile error) is reported as Warning: Skipping native plugin at '<dir>': <reason>. The other plugins still load. A panic inside your code does not stop mrsm: the call returns a failure (native plugin panicked) and the next command works normally. The host caches the manifest under $MRSM_ROOT_DIR/cache/native_plugins_manifest.json, keyed by a fingerprint of the plugin directory (the source fingerprint for a source crate), so replacing or removing a plugin takes effect on the next run (do it while mrsm is not running).

To check a source and its hooks, register a pipe on a SQLite instance and sync it:

mrsm register pipe -c plugin:my_plugin -m demo -i sql:local --params '{"columns": {"id": "id"}}'
mrsm sync pipes -c plugin:my_plugin -m demo -i sql:local --nopretty
mrsm plan pipes -c plugin:my_plugin -m demo -i sql:local --nopretty    # the plugin as a graph source
mrsm start api -i sql:local --host 127.0.0.1 -p 8224 --no-dash        # your routes are served next to the core API

What the repository tests

These suites build the example plugins in crates/meerschaum-plugin/examples/ (native_demo, native_probe, native_api, bad_abi) and load them as real shared libraries; all data is synthetic. Run each with a temporary MRSM_ROOT_DIR and MRSM_PLUGINS_DIR:

cargo test -p meerschaum-plugin                       # the SDK and loader
cargo test -p meerschaum-cli --test native_plugins    # the `mrsm` binary: actions, flags, a native source
cargo test -p meerschaum-cli --test native_e2e        # hooks, routes, shadowing, rejection, cache
cargo test -p meerschaum-cli --test native_api_mcp    # routes with parameters, scopes and bytes; MCP over HTTP and stdio
cargo test -p meerschaum-cli --test native_source_plugin   # a Cargo crate built, cached and rebuilt by mrsm (compiles the SDK: slow the first time)
cargo test -p meerschaum-cli --test native_host_calls      # host calls: pipes, SQL, plugin config, actions, routes with scopes
MRSM_TEST_CONSOLE=1 PYTHONPATH=$(pwd) python -m pytest tests/console -k native_page   # a native console page in Chromium
Capability Covered by
Fetch source, sync override, in-memory instance, actions, hooks, panic containment (library level) meerschaum-plugin/tests/native.rs
Action dispatch, show plugins/show actions, flags, panic containment, a sync from a native source native_plugins.rs
Native hooks around real syncs, the built-in sync writing a native fetch, failing hooks as warnings native_e2e.rs
Routes (public, authenticated, --no-auth) served by mrsm start api native_e2e.rs
api_routes (path parameters, headers, bytes, scopes, a refused shadowing route) and MCP tools, resources and prompts over /mcp and stdio native_api_mcp.rs; the server side in meerschaum-server/tests/native_extensions.rs, the ABI in meerschaum-plugin/tests/api_mcp.rs
A native plugin replacing a Python plugin of the same name native_e2e.rs (needs a Python environment; skipped without one)
A custom instance (-i native_demo:memory): pipes listed and synced, file-lock fallback for leases native_e2e.rs
Corrupt library and wrong ABI skipped, manifest cache follows the directory native_e2e.rs; discovery details in native.rs
plan pipes / show graph with a native source native_e2e.rs
A source crate built by mrsm, reused on the next run, rebuilt after an edit; a mismatched crate name and a missing cargo skipped native_source_plugin.rs (real cargo); stamp, fingerprint and failure handling in meerschaum-plugin/tests/source.rs
Host calls through mrsm on SQLite: another pipe's data, read_sql, clearing and rewriting a window, replace in one transaction, plugin config written by an action and read by register, exec_sql; route host calls refused without the caller's scope, internal tables refused, exec_sql only for callers who may run actions, host_scopes for anonymous callers native_host_calls.rs; both ABIs against a recording host (stream batches and executor methods too), old hosts, the generated manifest and Frame::from_rows in meerschaum-plugin/tests/host_calls.rs; config isolation, page viewers' scopes and the SQL checks in meerschaum-cli's native_plugins unit tests
A console page rendered in the Dash console's frame, identical to the Python page tests/console/test_console.py

πŸ”¬ The ABI in one page

The library exports a single symbol, mrsm_plugin_v1, which returns a static #[repr(C)] table:

Field Purpose
abi_version, struct_size A permanent prefix. The host reads only these two u32s before rejecting an incompatible or truncated table.
describe() Returns metadata and the manifest as JSON.
invoke(request_json, input_stream, output_stream) One entry point for every method; request.method selects the operation. Input and output are Arrow streams.
free_bytes(bytes) Releases every payload the plugin allocated.

Every result carries a status: 0 success (JSON), 1 error (UTF-8 message), 2 caught panic, 3 unsupported method. Memory a plugin allocates is always released by that plugin's free_bytes; the host never frees it.

The host serializes a plugin's calls, except the stream methods (next_batch, ack_batches, close_subscription, executor.next_logs, executor.close_logs): those may run while another call is in progress, so one blocking next_batch never holds up the rest of the plugin. The SDK locks each open handle itself.

Single-file plugins additionally export mrsm_plugin_invoke_rows_v1. This extension exchanges JSON request, result and row bytes, with call-scoped host callbacks; it does not change the original Arrow ABI. Host and plugin always free their own allocations. JSON rows trade conversion overhead and schema fidelity for small builds; full Cargo plugins use the Arrow path described above.

The optional mrsm_plugin_invoke_host_v1 symbol accepts the original request and Arrow streams plus a versioned host callback table (HostV1), through which the plugin makes its host calls. Its context is valid only during the call; result bytes are freed by the host that allocated them. A call's method bytes are a bare name (get_sync_time, sync: the original calls, on the request's Pipe) or a JSON envelope {"method", "pipe", "args"} (pipe is the addressed Pipe's metadata, null for the request's); the Arrow stream carries the plugin's rows to the host (sync) and, on return, the host's rows back (get_data, read_sql). A host that does not know a method answers "unsupported", which the SDK reports as a call this Meerschaum cannot run. The rows extension carries the same envelope with data rows. Older libraries use the original v1 entry point unchanged; new plugins loaded by an older host still run, and only their new host calls fail.

The ABI version is checked at load. A breaking change would ship as a new mrsm_plugin_v2 symbol, and the host keeps loading v1 for at least one major release. The same interface is also written as WIT (crates/meerschaum-plugin/wit/plugin.wit) for the future WebAssembly host.

πŸ“¦ Distribution

mrsm install plugins readings -r api:repo accepts a repository tar.gz archive containing readings.rs, or a readings/ directory with plugin.toml and either Cargo sources or a prebuilt library for the host OS and CPU. Source plugins are built and validated during installation. Native installs skip Python virtual environments and Python dependency installation, run an optional native setup hook, record .mrsm_origins.json, and restore the previous plugin if compilation, loading or setup fails. uninstall plugins removes single-file Rust sources as well as native directories. A descriptor supplies the version for normal up-to-date checks; single .rs files have no author version and are replaced on each install. --skip-deps does not skip the Rust build needed to validate a source plugin.

Upload either format with mrsm register plugins readings -r api:repo. Single-file uploads retain the inline dependency block. Packaged uploads retain Cargo sources, manifests, lockfiles, assets, .cargo configuration, and any shipped library; build output, other hidden files, symlinks, and files matched by .gitignore are excluded. Local path dependencies must be included inside a packaged plugin or replaced with registry or Git dependencies before sharing; uploading a single .rs file does not upload its neighboring files.

You may also copy these files directly into MRSM_PLUGINS_DIR. Automatic target-triple selection is not implemented; a prebuilt library must match the host OS and CPU. mrsm edit plugins readings opens the .rs file or the Cargo library entry point ([lib].path, default src/lib.rs). Binary-only packages must include sources to be edited.

Status in 5.0

Source plugins (a Cargo crate built by Meerschaum into the root's cache) work when cargo is on PATH; the prebuilt lib<name>.<ext> remains the contract for the slim image. Discovery, the manifest cache, actions, arguments, hooks, source fetch/sync, custom connectors, routes and pages are implemented, and each is exercised through the mrsm binary by the tests listed under Testing your plugin. The deepest instance operations (reads, rowcounts, maintenance) are exercised only at the library level; through the binary the example instance proves resolution, fetch_pipes_keys, sync_pipe and the lease fallback. Job executors and streaming connectors (subscribe, pulled batch by batch) are covered in Executors and Streams. Not yet available: target-triple selection by install plugin, and live reload of an already-open library (replace a plugin while mrsm is not running).

Single-file reference

These details apply to the single-file format, without a Cargo project.

Accepted signatures

For data sources, define fetch, sync, or both. Each function may accept any subset of these named parameters, in any order:

Parameter Type Meaning
pipe &Pipe Pipe metadata and operations on its host instance
connector &Connector Current connector type, label, and resolved configuration
kwargs &Value Options passed to the handler
begin Option<Bound> Inclusive lower bound, or None when not supplied / unbounded
end Option<Bound> Exclusive upper bound, or None when unbounded
data (sync only) Option<Frame> Input frame if the caller supplied one

For example, fetch(pipe: &Pipe), fetch(end: Option<Bound>), and fetch(pipe: &Pipe, begin: Option<Bound>, end: Option<Bound>) are all valid. Leading underscores are allowed (_pipe, _begin); use a named parameter rather than _. Names distinguish begin from end, whose types are identical. Functions must be synchronous, safe, non-generic Rust functions, declared at the file's top level or inside a marked connector module. Request is an internal dispatch envelope, not an accepted single-file parameter.

Pipe, Connector, Bound, SuccessTuple, JSON Value, json!, Frame, Result, and Error are available without imports, and so are host and Query (host calls). Bound is either Bound::Int(i64) or Bound::Datetime(String) (an ISO timestamp): bound.as_int(), bound.timestamp() (Unix seconds; a timestamp without an offset is UTC), Bound::from_timestamp(seconds) and Bound::from("2024-01-01") convert them. Fetch receives the engine's resolved bounds; an unspecified default begin in a sync override is None.

Fetch may return Vec<Value> containing JSON objects, Option<Vec<Value>>, Frame, Option<Frame>, or any of those wrapped in Result. Missing row fields become null cells. As with a Python plugin's fetch(), an empty vector is zero rows (the sync succeeds with "No new rows were returned") and None is no data (the sync fails with "No data were fetched"); the same holds for an instance connector's get_pipe_data (an empty frame vs None), and pipe.sync(vec![]) syncs zero rows like pipe.sync([]). Single-file plugins exchange JSON rows over a versioned C ABI; the host converts them to its native frames. Frame here is a lightweight row container with from_rows, rows, into_rows and height, not the full SDK's Arrow-backed frame. Use a Cargo plugin for typed Arrow data, preserved frame schemas, or optimized bulk processing. Sync may return SuccessTuple, (bool, String), (bool, &str), or a Result of those. If both functions exist, the engine uses sync as the override, as with Python plugins. A normal engine-invoked sync override receives data = None; explicit input is written by the engine without invoking the override again.

Sync hooks

Mark a top-level function with #[pre_sync_hook] or #[post_sync_hook], the equivalents of Python's @pre_sync_hook and @post_sync_hook. The function name is the hook's name; a file may define several hooks of each kind, which run in file order.

#[pre_sync_hook]
fn announce(pipe: &Pipe) -> (bool, String) {
    (true, format!("Syncing {}", pipe.metric_key()))
}

#[post_sync_hook]
fn report(pipe: &Pipe, success_tuple: SuccessTuple, sync_duration: f64) -> (bool, String) {
    (true, format!("{} took {sync_duration:.1}s: {}", pipe.metric_key(), success_tuple.message))
}
Parameter Type Meaning
pipe &Pipe The synced Pipe, with its host calls
kwargs &Value The keyword arguments Python hooks receive (begin, end, params, sync_timestamp, and after the sync success_tuple, sync_duration, sync_complete_timestamp, ...)
success_tuple (post only) SuccessTuple The sync's result
sync_duration (post only) f64 Seconds the sync took

A hook may return (), SuccessTuple, (bool, String) / (bool, &str), or a Result of those. () counts as (true, "Success"), as a Python hook returning None does. The results are printed by sync pipes and never fail the sync (see What a plugin can provide). As in Python, a pipe.sync(...) inside a hook runs no hooks.

Markers stack like Python's decorators: one function may carry #[make_action], #[pre_sync_hook] and #[post_sync_hook] together, and is registered once for each (its parameters must suit every role, so kwargs: &Value is the usual choice).

Setup

A top-level fn setup runs during mrsm setup plugins <name> and after mrsm install plugins, like Python's setup(**kwargs). It may accept kwargs: &Value and returns bool, SuccessTuple, (bool, String), or a Result of those (an Err is a failed setup). A bare true or false gets the message "Setup for Plugin '' did not return a message." A setup function must return a value: Python fails a setup() that returns None, so fn setup() without a return type is rejected. A top-level setup with parameters other than kwargs is an ordinary helper function, as it was before single-file plugins had a setup.

fn setup(kwargs: &Value) -> (bool, String) {
    (true, format!("Ready (force = {})", kwargs["force"]))
}

setup may make host calls, for example to write the plugin's default settings once:

fn setup() -> Result<SuccessTuple> {
    if host::plugin_config()?.is_null() {
        host::write_plugin_config(&json!({"stations": ["north"], "factor": 1.0}))?;
    }
    Ok(SuccessTuple::ok("Ready."))
}

Plugin arguments

#[add_plugin_argument(...)] takes the arguments of Python's add_plugin_argument (argparse's add_argument): the quoted flags first, then key = value options. Put it on a top-level function, or at the top of the file as an inner attribute. Flags belong to the whole plugin either way, as in Python, and every action receives them in kwargs.

#![add_plugin_argument("--times", "-n", type = int, default = 1, help = "Repeat the greeting.")]

#[add_plugin_argument("--loud", action = "store_true", help = "Shout.")]
#[make_action]
fn greet(action: Vec<String>, kwargs: &Value) -> (bool, String) {
    let mut text = format!("Hello, {}!", action.join(", "));
    if kwargs["loud"] == true {
        text = text.to_uppercase();
    }
    (true, text.repeat(kwargs["times"].as_u64().unwrap_or(1) as usize))
}

mrsm greet Alice --loud -n 2 prints HELLO, ALICE!HELLO, ALICE!. Supported options:

Option Values
action "store" (default), "store_true", "store_false", "store_const", "append", "append_const", "count"
type int, float or str (bare or quoted), for store and append
default, const A string, number or bool literal (const only with store_const / append_const)
choices An array of literals, e.g. choices = ["north", "south"]
help, metavar, dest Strings. dest defaults to the first long flag with dashes as underscores (--dry-run is kwargs["dry_run"])
required true or false

nargs is not supported; use action = "append" to collect a flag given several times. As with Python plugins, a flag that was not given (or is false) is absent from kwargs, and a declared default is filled in. Values are checked as argparse checks them: a value that is not a valid int / float or not one of the choices fails the command with argparse's message (argument --times/-n: invalid int value: 'x'). A required flag is required when one of the plugin's own actions runs (other commands are unaffected).

A flag may not reuse an option string that is already taken, by the built-in CLI (-t is --tags, -d is --debug, ...) or by another plugin: as Python's add_plugin_argument does, Meerschaum warns (conflicting option string) and ignores that flag. Run mrsm --help to see the built-in flags.

Version and requirements

Declare a version and requirements with top-level constants, the Rust spelling of Python's __version__ and required:

const VERSION: &str = "1.2.0";
const REQUIRED: &[&str] = &["plugin:noaa", "requests>=2"];

Both must be literals with exactly these types (&str and &[&str]); Meerschaum reads them from the source without building the plugin. A VERSION or REQUIRED constant of another type (say const VERSION: u32) is an ordinary constant and declares nothing. VERSION is the plugin's version (0.0.0 when omitted): register plugins uploads it, and install plugins skips a repository plugin whose version matches the installed one, as it does for Python plugins. REQUIRED lists Python packages and plugin: requirements that install required (and install plugins) install into the plugin's virtual environment, exactly like a Python plugin's required list. Rust crates belong in the Cargo block, not in REQUIRED.

Talking to Meerschaum

A plugin reaches the Meerschaum process that runs it through host calls: methods of its Pipe and the functions of host. They are the Rust counterparts of what a Python plugin does with its pipe argument, mrsm.Pipe(...), a SQL connector and get_plugin_config(). Single-file plugins have host and Query without imports; Cargo plugins use meerschaum_plugin::{host, Query} (both are in meerschaum_plugin::prelude).

/// Rebuild the daily averages of the `readings` pipe.
fn sync(pipe: &Pipe) -> Result<SuccessTuple> {
    let readings = host::pipe("plugin:readings", "readings", None, None)?;
    let daily = host::read_sql(
        pipe.instance_keys(),
        &format!(
            "SELECT date(ts) AS day, station, AVG(temperature) AS temperature FROM {} GROUP BY 1, 2",
            readings.target()
        ),
    )?;
    // One transaction: readers never see the averages half rewritten.
    pipe.replace(daily, None, None)
}
Call Returns Python equivalent
host::pipe(connector, metric, location, instance) Pipe mrsm.Pipe(connector, metric, location, instance=instance) with its registered parameters
pipe.get_data(begin, end, params) rows pipe.get_data(begin=begin, end=end, params=params)
pipe.get_data_with(&Query::new()...) rows pipe.get_data(select_columns=, omit_columns=, begin=, end=, params=, limit=, order=)
pipe.get_rowcount(begin, end, params) i64 pipe.get_rowcount(...) (0 without a table)
pipe.get_sync_time(), pipe.get_sync_time_with(params, newest) Option<Bound> pipe.get_sync_time(params=, newest=)
pipe.sync(rows) SuccessTuple pipe.sync(docs)
pipe.replace(rows, begin, end) SuccessTuple pipe.sync(docs, begin=, end=, replace=True)
pipe.clear(begin, end, params) SuccessTuple pipe.clear(begin=, end=, params=)
pipe.exists(), pipe.get_columns_types(), pipe.register() bool, a map, SuccessTuple pipe.exists(), pipe.get_columns_types(), pipe.register()
pipe.target(), pipe.datetime_column(), pipe.parameters(), pipe.with_parameters(json!({...})) pipe.target, pipe.columns['datetime'], pipe.parameters, mrsm.Pipe(..., parameters=...)
host::read_sql(connector, query) rows mrsm.get_connector(connector).read(query)
host::exec_sql(connector, statement) SuccessTuple mrsm.get_connector(connector).exec(statement)
host::plugin_config() Value (null when unset) get_plugin_config()
host::write_plugin_config(&json!({...})) SuccessTuple write_plugin_config({...})
host::config(&["meerschaum", "instance"]) Value get_config('meerschaum', 'instance')

"Rows" are Vec<Value> JSON objects in a single-file plugin (datetimes as ISO strings) and an Arrow-backed Frame in a Cargo plugin (frame.to_rows() gives the JSON objects). begin and end are Option<Bound> (None is unbounded; begin is inclusive and end exclusive), and params is Option<&Value>, the params filter of Python's get_data (Some(&json!({"station": ["a", "b"]}))). A Pipe without a table has no rows and a row count of 0; exists() tells the two cases apart.

  • Which instance. host::pipe without an instance uses the instance of the Pipe being synced; in an action, the -i instance (else the configured meerschaum:instance); in a route, MCP entry or page, the API server's instance.
  • Writes. pipe.sync(rows) writes through the normal sync (dtype enforcement, existing-row checks, upserts) and registers a Pipe that is not registered yet, with the view's parameters (with_parameters). On the Pipe being synced it inherits the surrounding sync's options; on any other Pipe it uses the defaults. It never calls a plugin's fetch or sync again: supply the rows. pipe.replace(rows, begin, end) rewrites a window in one transaction: the rows of [begin, end) that are not in rows are deleted and the others written, and readers never see the window half rewritten. A None bound comes from the rows (begin is their earliest datetime, end just after their latest), so rows outside their range stay; no rows with both bounds empties the window. Writes to a protected instance are refused unless the command has --allow-protected (or MRSM_ALLOW_PROTECTED=1); the refusal comes back as a failed SuccessTuple.
  • SQL. read_sql and exec_sql take any configured sql: connector's keys, including pipe.instance_keys() for the Pipe's own database. Quote table names for your database.
  • Plugin config. plugin_config() reads the plugins:<name> section of the Meerschaum config (mrsm edit config plugins) and write_plugin_config replaces it in the config directory's plugins file (plugins.json unless you keep it as YAML), keeping the other plugins' sections as the file has them; it is the section Python's get_plugin_config() reads and write_plugin_config() writes. The config is the command's own (a compose project's root, for example), and later reads in the same process see the write: the next command in the shell, the next --loop run, and the Python process's next call.

Host calls work while Meerschaum calls the plugin: fetch, sync, register, setup, the sync hooks, a connector's subscribe and each of its batches, an executor's methods, actions, routes, MCP tools, resources and prompts, and console pages. In register and an executor's methods they have the trust of the process, as a Python plugin's do (also when a registration comes through the API). They are synchronous and tied to the call and its thread: a Pipe cannot be sent to background threads, and a call outside a plugin call returns an error. Re-entering the same native library through a host call returns an error instead of waiting on its own call lock. Importing helpers from Python plugins (from_plugin_import) has no Rust counterpart: port the helper too.

In routes, MCP entries and console pages, host calls act for the caller, with the scopes the core API would require:

Calls Scope
host::pipe, get_data, get_rowcount, get_sync_time, get_columns_types, exists pipes:read
sync, replace pipes:write (also pipes:register when creating a pipe)
clear pipes:delete
register pipes:register
read_sql (read-only queries only) sql:read
exec_sql actions:execute, and a caller who may run actions (below)
config every scope (*: an administrator, or --no-auth)
plugin_config, write_plugin_config none: the plugin's own settings
  • Pipes are reached only on the instances the server allows (api:permissions:instances), and never Meerschaum's internal mrsm pipes. The caller must be able to see the pipe, and writes require its edit rights. Registered parameters and locks come from the instance; changing a plugin's local Pipe metadata cannot override them. New pipes receive the caller's owner and pass the same registration, connector-team and SQL-definition checks as HTTP.
  • SQL reaches only served sql: instances whose connector teams allow the caller. It refuses targets of hidden pipes and Meerschaum's internal tables: a query or statement that names one (mrsm_users, mrsm_tokens, any mrsm_* table or the _mrsm_internal schema, quoted or in a string) or names a function that runs query text or reads tables named in text (query_to_xml, database_to_xml, ts_stat, dblink, Oracle's DBMS_XMLGEN, DuckDB's query, ...) is refused. This is a keyword check, not a parser: give untrusted callers a read-only database role.
  • exec_sql follows the server's /actions rule: administrators may; other users and tokens only when the server lets non-administrators run actions (system:api:permissions:actions:non_admin: true, with jobs:execute too), never under start api --secure; anonymous callers never.
  • Who has which scopes. A token or a signed-in API user has its current permissions within the scopes the request was granted. Host calls recheck credential validity, permissions and team membership; a revoked token or deleted user cannot continue making them. A console page's signed-in viewer has what their user would get from /login (every scope for an administrator, else the user's scopes). A caller without credentials (a public = true route, a guest of a page with login_required = false) has none, so these calls are refused for it, unless the route or page declares host_scopes: every caller's host calls get these named scopes on top of its own, preserving visibility and edit rules. This public route serves Everyone's pipe data:

    #[api_route(GET, "/status/{metric}", public = true, host_scopes = ["pipes:read"])]
    fn status(request: &RouteRequest) -> Result<Value> {
        let pipe = host::pipe("plugin:readings", request.path_param("metric").unwrap_or(""), None, None)?;
        Ok(json!({"rows": pipe.get_rowcount(None, None, None)?}))
    }
    

    #[web_page("status", login_required = false, host_scopes = ["pipes:read"])] does the same for a page (Cargo plugins: RouteSpec::host_scopes([...]) and Manifest::page_host_scopes(path, [...])). * is not accepted, and exec_sql and host::config still need the caller's own permission. - Under --no-auth every call is allowed. MCP over stdio (mrsm start mcp) has the full trust of the local user.

A host older than these calls answers each new one with an error such as "This Meerschaum cannot run pipe.get_data() for plugins: upgrade Meerschaum to 5.0.0rc15 or later."; the original pipe.get_sync_time() and pipe.sync(data) still work there.

An integer-axis sync plugin can be just:

fn sync(pipe: &Pipe) -> Result<SuccessTuple> {
    let next = match pipe.get_sync_time()? {
        Some(Bound::Int(last)) => last + 1,
        None => 1,
        _ => return Err(Error::Message("Expected an integer axis".into())),
    };
    pipe.sync(vec![json!({"id": next})])
}

Register an integer axis for this example:

mrsm register pipe -c plugin:readings -m readings -i sql:local --params '{"columns":{"datetime":"id"},"dtypes":{"id":"int"}}'
mrsm sync pipes -c plugin:readings -i sql:local

API routes

#[api_route(...)] on a top-level function serves it from mrsm start api, the native counterpart of an @api_plugin route (the routes guide has the server's rules). The method comes first (an identifier such as GET or a string such as "post"; "*" is any method, and it defaults to GET when only the path is given), then the path, then options:

/// One item; the doc comment is the route's description.
#[api_route(GET, "/readings/items/{id:int}", scopes = ["pipes:read"])]
fn item(request: &RouteRequest) -> Result<Value> {
    let id: i64 = request.path_param("id").unwrap_or("0").parse().map_err(|e| Error::Message(format!("{e}")))?;
    Ok(json!({"id": id, "q": request.query_param("q"), "user": request.principal}))
}

#[api_route(POST, "/readings/upload/{*name}", scopes = ["pipes:write"], name = "upload")]
fn upload_file(request: &RouteRequest) -> (u16, String) {
    (201, format!("{} bytes for {}", request.body.len(), request.path_param("name").unwrap_or_default()))
}

#[api_route("/readings/health", public = true)]
fn health() -> RouteResponse {
    RouteResponse::text("ok").header("cache-control", "no-store")
}
Option Meaning
scopes = ["..."] Scopes the caller must hold (with the API's credentials)
public = true Served without credentials; a public route may not list scopes
name = "..." The route's name (default: the function's name)
description = "..." Overrides the doc comment

Paths use the Starlette syntax ({id}, {name}.json, {n:int}, a final {*rest} or {rest:path}). The function may take request: &RouteRequest: method, path, route_path, path_params, query, query_params, headers (lowercase names, without authorization), body (bytes), principal (Option<Principal> with kind, username, user_id, user_type, token_id, label) and scopes, with the helpers path_param, query_param, header, cookie, text, json::<T>() and has_scope. It returns a RouteResponse (json, text, bytes, empty, error, then with_status, header, with_body), a JSON Value (application/json), a String or &str (text/plain), Vec<u8> (application/octet-stream), (u16, T) to set the status, or any of these in a Result (an Err is a 500 {"detail": message}). Route functions run concurrently, so they may not use pipe, and shared state needs a Mutex.

The path is checked when the plugin is built, with the rules mrsm start api applies: bad syntax ("/a/{x:float32}", a {*rest} that is not last) or a path that would shadow a core route (a parameter in the first segment, a core route group such as /pipes, /dash, /_pages, /static, /_mrsm) is an error at the path's line, not a notice at server startup.

MCP tools, resources and prompts

#[mcp_tool], #[mcp_resource] and #[mcp_prompt] on top-level functions expose them through /mcp and mrsm start mcp (see the MCP guide). The doc comment is the description unless description = "..." is given; tools and resources need one. Every entry takes name = "..." (default: the function's name), title = "..." and scopes = ["..."]. JSON options are string literals, usually raw strings.

/// Count the rows of a synthetic table.
#[mcp_tool(read_only = true, input_schema = r#"{"type": "object", "properties": {"table": {"type": "string"}}, "required": ["table"]}"#)]
fn count_rows(call: &ToolCall) -> Result<Value> {
    let table = call.argument("table").and_then(Value::as_str).unwrap_or_default();
    Ok(json!({"table": table, "rows": 0, "caller": call.principal}))
}

/// One reading.
#[mcp_resource(uri_template = "readings://items/{id}", scopes = ["pipes:read"])]
fn reading(resource: &ResourceRead) -> ResourceContents {
    ResourceContents::json(&json!({"id": resource.variable("id")}))
}

/// Summarize a station.
#[mcp_prompt(arguments = ["station"])]
fn summarize(prompt: &PromptGet) -> String {
    format!("Summarize the readings of {}.", prompt.argument("station").unwrap_or("?"))
}
Attribute Options Parameters Returns
#[mcp_tool] input_schema (a JSON Schema object, checked when the plugin is built; default: no arguments), output_schema, read_only = true (readOnlyHint, so read-only servers still offer it), annotations (a JSON object) call: &ToolCall (name, arguments, principal, scopes, argument(name), arguments::<T>()), arguments: &Value any serializable value in a Result, a JSON Value or a String
#[mcp_resource] uri or uri_template (exactly one), mime_type resource: &ResourceRead (name, uri, variables, variable(name), principal, scopes) ResourceContents (text, json, blob), a String, a JSON Value, or a Result of those
#[mcp_prompt] arguments: ["name", ...] (required arguments) or a JSON array of {"name", "description"?, "required"?} prompt: &PromptGet (name, arguments, argument(name), principal, scopes), arguments: &Value a String (one user message), Vec<Value> messages, a JSON Value {"description"?, "messages"}, or a Result of those

Tool arguments are validated against input_schema before your function runs. Like routes, these functions run concurrently and without pipe. A prompt message with another role is meerschaum_plugin::mcp::message("assistant", text), as in the Cargo SDK.

Executors

#[make_executor] on a top-level inline module declares the executor type named after the module (#[make_executor(name = "procs")] to choose another; local, systemd, api and none are reserved). Its functions named after the executor methods answer them; others are helpers. A method you leave out answers "not supported", except those derived from others, exactly as in the Cargo SDK: get_job_exists and get_jobs from get_job_names and get_job_metadata, and get_job_properties, get_job_status and get_job_began / _ended / _paused from the metadata.

/// Run each job as a local process.
#[make_executor]
mod procs {
    fn get_job_names(executor: &ExecutorInfo) -> Vec<String> { /* list the jobs' directories */ vec![] }
    fn get_job_metadata(executor: &ExecutorInfo, name: &str) -> Result<Value> { /* null when missing */ Ok(Value::Null) }
    fn create_job(executor: &ExecutorInfo, name: &str, sysargs: Vec<String>, properties: &Value) -> Result<SuccessTuple> {
        // Start `executor.mrsm` + `sysargs` under `executor.root_dir`, then report it.
        Ok(SuccessTuple::ok(format!("Started job '{name}'.")))
    }
    fn stop_job(name: &str) -> (bool, String) { (true, format!("Stopped job '{name}'.")) }
    fn monitor_logs(name: &str, stop_on_exit: bool) -> impl FnMut(Duration) -> LogEvent {
        let mut done = false;
        move |_timeout| if std::mem::replace(&mut done, true) { LogEvent::End(None) } else { LogEvent::Lines(vec!["started".into()]) }
    }
}

Every method may take executor: &ExecutorInfo (executor_type, label, config (the connector configuration of <type>:<label>), root_dir (the root the jobs belong to, a compose project's root for its jobs; key file state on it), mrsm (the command that runs this Meerschaum: start a job as mrsm followed by its sysargs) and keys()), and name: &str where the method has a job. create_job also takes sysargs: Vec<String> (or &[String], without -d / -e) and properties: &Value (or Value), write_stdin takes data: &str, and monitor_logs takes strip_timestamps: bool and stop_on_exit: bool.

Method Returns
get_job_names Vec<String>
get_job_exists, get_job_is_blocking_on_stdin bool
get_jobs, get_job_metadata, get_job_properties, get_job_prompt_kwargs Value
get_job_status String or &str (running, paused or stopped)
get_job_began, _ended, _paused, get_logs, get_job_stop_time Option<String>
create_job, start_job, stop_job, pause_job, delete_job (bool, String), (bool, &str) or SuccessTuple
write_stdin ()
monitor_logs a closure FnMut(Duration) -> LogEvent (or -> Result<LogEvent>) called for each poll, a type implementing LogStream (next_lines, optional close), or Box<dyn LogStream> (the Cargo SDK's type)

Any of these may be wrapped in Result (an Err is reported as the executor's failure). LogEvent is Lines(Vec<String>), Timeout or End(Option<SuccessTuple>); the closure may block up to the timeout it receives, and it is dropped when the host stops following. show logs follows a job through monitor_logs; an executor without it shows its logs only with show logs --nopretty, which reads get_logs.

Streaming connectors

fn subscribe inside a #[make_connector] module makes the connector type a stream source for sync pipes --listen and the engine's supervised streams, the counterpart of an IS_STREAMING connector's subscribe. A top-level fn subscribe does the same for plugin:<name>. subscribe may take pipe: &Pipe (with its host calls, while the stream opens), connector: &Connector and kwargs: &Value (the sync's keyword arguments, as Python's subscribe receives them) and returns, optionally in a Result, a closure impl FnMut(Duration) -> Batch (or -> Result<Batch>), an impl Subscription, a Box<dyn Subscription> (the Cargo SDK's type), or a type the file implements Subscription for. A function named subscribe with other parameters or another return type is an ordinary helper, at the top level and in a connector module, as it was before streaming connectors (a connector module whose only function is such a subscribe is an error that says why).

/// Readings as they arrive.
#[make_connector]
mod ticker {
    fn subscribe(pipe: &Pipe, kwargs: &Value) -> Result<impl FnMut(Duration) -> Batch> {
        let station = pipe.parameters()["fetch"]["station"].as_str().unwrap_or("a").to_owned();
        let mut next = 0;
        // Inside `Ok(...)`, Rust needs the closure's parameter type spelled out.
        Ok(move |timeout: Duration| {
            std::thread::sleep(timeout.min(Duration::from_secs(1)));
            next += 1;
            Batch::RowsAck(vec![json!({"id": next, "station": station})], next)
        })
    }

    // Optional: the tokens of batches whose rows committed.
    fn ack(tokens: &[u64]) -> Result<()> {
        Ok(())
    }
}

The host calls the closure for each batch, with the longest time it may block, and only after the previous batch reached the sink, so a slow sink slows the source down. Batch is Rows(rows), RowsAck(rows, token) (acknowledge token once the rows committed; empty rows still get their token acknowledged), Data { rows, ack, redelivered } (redelivered forces check_existing), Timeout or End (the host closes the subscription and subscribes again after a backoff). Rows are JSON objects, as fetch returns them. The optional module-level fn ack(tokens: &[u64]) (it may also take connector: &Connector) runs after the subscription's own ack; a type implementing Subscription may instead override ack(&mut self, tokens) and close(&mut self). The returned closure lives beyond the call, so it cannot borrow pipe or kwargs: copy what it needs, as station above (Meerschaum adds + use<> to an impl return type for you, so a borrow is reported at your line).

A producer thread can feed a ChannelSubscription, as in the Cargo SDK: a full channel blocks the producer, and the subscription ends when every sender is dropped.

#[make_connector]
mod feed {
    fn subscribe(pipe: &Pipe) -> Result<Box<dyn Subscription>> {
        let count = pipe.parameters()["fetch"]["count"].as_u64().unwrap_or(3);
        let (sender, subscription) = ChannelSubscription::new(1);
        std::thread::spawn(move || {
            for id in 0..count {
                let rows = Frame::from_rows(vec![json!({"id": id})]).unwrap();
                if sender.send(rows).is_err() {
                    return;
                }
            }
        });
        Ok(Box::new(subscription))
    }
}

Instance connectors

#[make_connector(instance)] (or instance = true) makes the module's connector type an instance, the counterpart of a Python connector with IS_INSTANCE = True: -i <type>:<label> then registers, syncs, lists and deletes pipes through it. Its functions named after the instance methods answer them; others are helpers. A method you leave out is "not implemented", so the host falls back where it can (file-based leases, for example). The module may also define fetch, sync and subscribe, as a source connector does.

/// Pipes and rows in memory.
#[make_connector(instance)]
mod memory {
    use std::collections::BTreeMap;
    use std::sync::Mutex;
    static ROWS: Mutex<BTreeMap<String, Vec<Value>>> = Mutex::new(BTreeMap::new());

    fn key(pipe: &Pipe) -> String { format!("{}|{}", pipe.connector_keys(), pipe.metric_key()) }

    fn register_pipe(pipe: &Pipe) -> SuccessTuple {
        ROWS.lock().unwrap().entry(key(pipe)).or_default();
        SuccessTuple::success()
    }
    fn pipe_exists(pipe: &Pipe) -> bool { ROWS.lock().unwrap().contains_key(&key(pipe)) }
    fn sync_pipe(pipe: &Pipe, data: Vec<Value>) -> (bool, String) {
        let count = data.len();
        ROWS.lock().unwrap().entry(key(pipe)).or_default().extend(data);
        (true, format!("Inserted {count} rows."))
    }
    fn get_pipe_data(pipe: &Pipe, begin: Option<Bound>, params: &Value) -> Vec<Value> {
        ROWS.lock().unwrap().get(&key(pipe)).cloned().unwrap_or_default()
    }
    fn get_sync_time(pipe: &Pipe, newest: bool) -> Option<i64> { None }
    fn fetch_pipes_keys(query: &Value) -> Value {
        Value::Array(ROWS.lock().unwrap().keys().map(|k| {
            let (c, m) = k.split_once('|').unwrap();
            json!([c, m, null])
        }).collect())
    }
}
Method Returns
register_pipe, delete_pipe, drop_pipe, edit_pipe, sync_pipe, clear_pipe, deduplicate_pipe, drop_pipe_indices, create_pipe_indices, compress_pipe, decompress_pipe, analyze_pipe, vacuum_pipe, partition_pipe SuccessTuple, (bool, String) or (bool, &str)
get_pipe_data Vec<Value> rows, Frame or Option<Frame>
test_connection, pipe_exists, acquire_pipe_lease, release_pipe_lease bool
fetch_pipes_keys [[connector_keys, metric_key, location_key], ...] (or a map of pipe IDs to those lists)
get_pipe_id, get_pipe_rowcount, get_pipe_size Option<i64> (or i64)
get_sync_time an integer, an ISO timestamp string, or None
get_pipe_attributes, get_pipe_columns_types, get_pipe_index_names, get_pipe_columns_indices a JSON object (Value, or any serializable map)

Any of these may be wrapped in Result; the value methods may return any serializable type. Parameters bind by name: pipe: &Pipe, connector: &Connector, data: Option<Frame> or data: Vec<Value> (the rows of sync_pipe), context: &Value (the call's whole JSON context, as NativeConnector sends it), and any field of the call: query, options and params (&Value), begin / end (Option<Bound>), select_columns, omit_columns, limit, order, check_existing, chunksize, extra, newest, patch, no_policy, full, chunk_minutes, holder (&str) and ttl_seconds. A field is read from the context, then from its query, then from its options; declare it as &Value, &str, or any owned type it deserializes into (bool, f64, Option<i64>, Option<Vec<String>>, ...).

Console pages

#[web_page] on a top-level function serves it as a page of the web console (meerschaum[dash]), the counterpart of Python's @web_page. The path defaults to the function's name and is served under /dash/; the options are Python's:

/// A report of the readings.
#[web_page("readings/report", login_required = false, page_group = "Readings")]
fn report(page: &PageRequest) -> String {
    let station = page.query_param("station").unwrap_or_default();
    format!("<h3>Readings for {station}</h3><p>Viewed by {}.</p>", page.username.as_deref().unwrap_or("a guest"))
}

#[web_page]
fn status() -> (u16, String) {
    (503, "<p>Not ready yet.</p>".into())
}
Option Meaning
a path (first) "readings/report" is served at /dash/readings/report (a leading /dash/ is accepted); default: the function's name
login_required = false Served without a console session (default true)
skip_navbar = true Hide the console's navigation bar (default false)
page_group = "..." The navigation group (default: the plugin's name)
dark_theme = false Opt out of the console's dark component theme (default true)

The function may take page: &PageRequest (path, the raw query, query_param(name), and username, None for a guest) and returns the page's HTML as a String or &str, (u16, T) to set the status, or a Result of those (an Err is a 500 page showing its message). The HTML follows the page contract (data-mrsm-figure / data-mrsm-map elements). The path is checked when the plugin is built: relative segments of letters, digits, - and _, no wildcards. Pages are fixed when the server starts.

Dependencies

The standard library and shorthand types need no declarations. To use an external crate, add one optional Cargo block at the top of the same file:

//! ```cargo
//! [dependencies]
//! regex = "1"
//! ```

use regex::Regex;

#[make_action]
fn count_words(action: Vec<String>) -> (bool, String) {
    let words = Regex::new(r"\w+").unwrap();
    let count = words.find_iter(&action.join(" ")).count();
    (true, format!("{count} words"))
}

Run mrsm count words hello world. Cargo versions, feature lists, renamed dependencies, Git dependencies, and local path dependencies use their normal Cargo syntax. Local dependency paths resolve relative to the original .rs file, rather than the cache. You can enable Serde features, for example serde = { version = "1", features = ["derive"] }; serde_json keeps the features required by the runtime.

Meerschaum generates the Cargo project and keeps its Cargo.lock in the build cache. Unchanged plugins reuse the built library; editing a dependency declaration triggers a rebuild. Dependencies are compiled into the plugin, so Cargo is needed for builds but not to use an already-built library. First builds may download crates. Nothing is installed into a Python virtual environment.

Only [dependencies] is supported in the inline block. Use a packaged Cargo plugin for workspace settings, build dependencies, custom profiles, or publishing a lockfile. Changes to external path dependencies are not fingerprinted: edit the .rs file or remove its build cache after changing such a dependency. Relative include_str! and other source paths still resolve from the cached source; use a Cargo plugin for assets.

Single-file build cache

The filename supplies the plugin name; the version is VERSION (0.0.0 when omitted). Sources and build output stay under <root>/cache/native_build/<name>/; nothing is written beside the original .rs file. Unchanged builds are reused, edits trigger recompilation, and failed builds are remembered until an edit or cache deletion. The small runtime is embedded by the running host; there is no published SDK dependency. Shared Cargo dependencies live in <root>/cache/native_build/.single_file_target/; each plugin has its own copied library and build stamp. Compiler diagnostics are saved in <root>/cache/native_build/<name>/mrsm-build-error.txt and shown again for failed builds.

Rust returns the final expression of a function. In the example above, do not put a semicolon after vec![...]: that would discard the rows and return () instead of Vec<Value>.

Use the full crate layout above for multiple source files, relative included assets, typed Arrow frames, or a hand-written manifest. Relative paths in a shorthand file resolve from its generated copy in the cache.