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](https://img.shields.io/crates/v/agent-plugins.svg)](https://crates.io/crates/agent-plugins)
[![docs.rs](https://img.shields.io/docsrs/agent-plugins)](https://docs.rs/agent-plugins)
[![CI](https://github.com/Toasterson/agent-plugin-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/Toasterson/agent-plugin-rs/actions/workflows/ci.yml)
[![license](https://img.shields.io/badge/license-MPL--2.0-blue.svg)](LICENSE)

A Rust implementation of the [Agent Plugins specification][spec] 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.

[spec]: https://agent-plugins.org/specification
[agent-plugin-ts]: https://github.com/Toasterson/agent-plugin-ts

```toml
[dependencies]
agent-plugins = "0.1"
```

## Loading a plugin

```rust
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 `Question`s — *what kind
of entry is `plugin.json`? does `skills/` resolve inside the root? read it;
list it* — and consumes `Answer`s from wherever the plugin actually lives:

```rust
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.

```rust
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:

```rust
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`](https://github.com/casey/just) drives everything:

```bash
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.

```bash
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`](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](LICENSE).