agent-plugins 0.1.1

Sans-IO implementation of the Agent Plugins specification (agent-plugins.org): load, validate, and prepare agent plugins from any IO
Documentation

agent-plugins

crates.io docs.rs CI license

A Rust implementation of the Agent Plugins specification v1.0.0 — the open, vendor-neutral format for packaging Agent Skills and MCP servers into distributable plugins.

Sans-IO at its core, so a plugin can be loaded from a directory, an archive, an object store, or an async runtime without the library knowing the difference. The TypeScript sibling lives at agent-plugin-ts and runs the same conformance corpus.

[dependencies]
agent-plugins = "0.1"

Loading a plugin

use agent_plugins::{load_dir, McpOutcome};

let plugin = load_dir("./my-plugin")?;

println!("{} v{}", plugin.manifest.name,
         plugin.manifest.version.as_deref().unwrap_or("unversioned"));

for skill in &plugin.skills {
    println!("{}: {}", skill.meta.name, skill.meta.description);
}

if let McpOutcome::Configured(mcp) = &plugin.mcp {
    println!("{} MCP server(s)", mcp.servers.len());
}

// The spec says "SHOULD report"; this is where those reports arrive.
for note in &plugin.diagnostics {
    eprintln!("note: {note}");   // server-skipped (§7.2.2) at mcp.json#legacy: …
}
# Ok::<(), agent_plugins::Rejection>(())

Design

Parse, don't validate. Every constraint the specification states gets a type that carries its proof. A PluginName has satisfied §5.5. A RelativePath has satisfied §4.1's spelling and containment rules. An McpUrl has satisfied §7.2.1 — absolute, no user information, no fragment, plain http only toward loopback. A Cwd can only be one of the three permitted working-directory forms, so "some other string" is never a state the rest of your code has to handle.

Failure boundaries are return types. The specification is unusually precise about how far a failure spreads, and the API mirrors it exactly:

Failure Boundary Type
Invalid manifest the whole plugin (§5.2) Rejection
Unknown top-level manifest field reported, ignored (§5.2) Diagnostic
Bad mcp.json MCP only (§7.2.2) McpOutcome::Disabled
Bad server entry that server (§7.2.2) Diagnostic
Bad skill that skill (§7.1) Diagnostic

Nothing fails silently, and nothing fails wider than the spec permits.

Sans-IO. Loading is a state machine that asks Questions — what kind of entry is plugin.json? does skills/ resolve inside the root? read it; list it — and consumes Answers from wherever the plugin actually lives:

let mut loading = Loading::start();
while let Some(question) = loading.question() {
    let answer = match &question {
        Question::Kind(path)  => stat(path).await,
        Question::Confined(_) => Answer::Confined(true),  // archives have no symlinks
        Question::Read(path)  => fetch(path).await,
        Question::List(path)  => index(path).await,
    };
    loading.answer(answer)?;
}
let plugin = loading.finish()?;

For synchronous sources, implement Source and call load. For directories and in-memory packages, load_dir and MemSource are built in.

Skill bodies are documents, not strings

Agent Skills load progressively: metadata at startup, the SKILL.md body on activation, referenced files only when needed. This crate takes that one step further — a skill's body arrives as a Document: a preamble plus a tree of sections, each with a slug, a span, its own prose, and its full content including subsections. Hand a model one section of a long skill instead of the whole file, and follow the in-package links to the resources it points at.

let doc = &skill.body;                          // a Document, not a String
for (depth, section) in doc.outline() {
    println!("{:indent$}{}", "", section.heading(), indent = depth * 2);
}

let rollback = doc.section(["Deploy", "Rollback"]).unwrap();
model.send(rollback.content());                 // just that subtree

for link in rollback.links() {
    if let LinkTarget::InPackage(path) = link.target {
        load_on_demand(path);                   // references/, scripts/, assets/
    }
}

The parser handles the two things that actually bite in skill bodies: fenced code is inert, so a # comment in a shell snippet is not a phantom heading, and section spans follow heading level, ending exactly where a sibling or shallower heading begins. Setext headings, skipped levels, and duplicate headings (numbered slugs, as anchors do) all behave.

Launching a stdio MCP server

LaunchPlan resolves every placeholder and sequences the §9.1 environment contract — configured overlay first, then PLUGIN_ROOT and PLUGIN_DATA replacing any same-name entry:

let anchors = Anchors::new("/plugins/my-plugin", "/plugins/data/my-plugin");
if let McpServer::Stdio(server) = &entry.server {
    std::fs::create_dir_all(&anchors.plugin_data)?;   // §9.1: client-managed
    let child = server.launch_plan(&anchors).to_command().spawn()?;
}

Conformance

This crate covers the client checklist in Appendix A of the specification: $schema-selected offline validation, the closed manifest schema with its exact fatal/non-fatal split, fixed-location component discovery, path containment, the closed MCP transport union, per-boundary failure isolation, and ${PLUGIN_ROOT}/${PLUGIN_DATA} expansion.

fixtures/cases/ pins the interesting boundaries — an invalid mcp.json that must not stop skills from loading, thirteen individually invalid server entries beside one good one, a skills that is a file, an mcp.json that is a directory, plugins from a future spec version. Each case is a plugin package plus an expected.json. The corpus is mirrored in agent-plugin-ts; when you change one, sync the other with just sync-fixtures so the two implementations keep agreeing case-by-case.

Development

just drives everything:

just            # list recipes
just setup      # install cargo-release and cargo-audit
just test       # tests, including the conformance corpus
just ci         # fmt-check, lint, test, doc — what CI runs, in CI's order

Releasing

Releases are tag-driven. cargo release bumps the version, rewrites the changelog, commits, tags, and pushes; the tag triggers CI, which re-runs every check on a clean runner and only then publishes to crates.io.

just release-dry patch   # see exactly what would happen
just release patch       # bump, changelog, commit, tag v0.1.1, push

A release needs the CARGO_REGISTRY_TOKEN secret set on the crates-io environment. See docs/RELEASING.md for the one-time setup, including how to switch to crates.io Trusted Publishing instead of a long-lived token.

License

Mozilla Public License 2.0.