# agent-plugins
[](https://crates.io/crates/agent-plugins)
[](https://docs.rs/agent-plugins)
[](https://github.com/Toasterson/agent-plugin-rs/actions/workflows/ci.yml)
[](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:
| 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).