Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
protocol-droid
A Rust toolkit for the Model Context Protocol
that speaks every published spec revision — 2024-11-05, 2025-03-26,
2025-06-18, 2025-11-25, 2026-07-28, and the floating draft — in a
single binary, all at once. It does not ask you to pick a version at compile
time. The A* protocols ride the same internal model — A2A v1.0 and ACP
v1.25.0 are vendored alongside the MCP schemas, the cross-protocol
bridge (protocol-droid bridge_a2a) translates between MCP and A2A
without a consumer having to re-implement their application, and a
consumer that only wants MCP never pays for the A* codecs (a2a and
acp-experimental are opt-in Cargo features).
📖 Read the guide —
the live Astro + Starlight site at greysquirr3l.github.io/protocol-droid/
hosts the full guide (introduction, FeatureProfile, write-a-server,
write-a-client, bridge, security model, spec-refresh, release). The site
is built from site/ in this repo and is updated on every pnpm run deploy.
Three ideas set it apart:
-
One canonical internal model, many wire codecs. Application code speaks a version-agnostic superset IR. Per-revision codecs lift wire messages into the IR and lower IR back to the wire for whatever version was negotiated. Schema types for every revision coexist; nothing is mutually exclusive.
-
Runtime feature gating driven by what the peer negotiated. A pure domain function turns (protocol version × client capabilities × server capabilities × transport) into a
FeatureProfile. Every feature-specific path consults it. Unavailable features fail with a typed error, or degrade under an explicit opt-in policy. Nothing is silently dropped. -
Every transport the spec has ever defined. stdio, legacy HTTP+SSE (
2024-11-05), Streamable HTTP (stateful), and the2026-07-28stateless lifecycle — each with both a server and a client half. The CLI on top is being built to a Swiss-army knife; today it hasbridge,probe,inspect,record,replay, andconformance.
Installing
[]
= "0.2"
The default build is serde + stdio, which is enough to serve over stdio
and to speak every protocol revision. Everything else is a feature, because a
consumer that links this as a library should not pay for a server it never
starts:
| Feature | What it turns on |
|---|---|
stdio |
The stdio transport, server and client. On by default |
http |
Streamable HTTP plus the 2026-07-28 stateless lifecycle |
legacy-sse |
The deprecated 2024-11-05 HTTP+SSE transport, client and server (HttpBuilder::legacy_sse()). Implies http |
oauth |
The OAuth 2.1 resource server and PKCE client. Implies http |
a2a |
The A2A v1.0 server, client, and cross-protocol bridge |
acp-experimental |
Zed's ACP v1.25.0. Pre-1.0, so it carries no semver guarantee |
random-ids |
CSPRNG session ids instead of a counter |
A simd envelope parser is described in the manifest but is not yet wired to a
feature — it exists so the two decoders can be benchmarked rather than argued
about, and it will not appear here until it does.
A lean build — the IR, every codec, every wire type, and no executor at all:
= { = "0.2", = false, = ["serde"] }
default-features = false drops the executor, the transport adapters, and the
diagnostics channel with it. What it never drops is a spec revision: the schema
types for all six are data, not features, so --no-default-features is a
smaller binary and never a smaller protocol surface.
The CLI is a separate crate, so it installs on its own:
It versions in lockstep with the library rather than independently — the four
crates are published from one workspace and share a version, which is what
makes cargo install protocol-droid-cli pull a protocol-droid that matches
what its bridge subcommand was built against.
Using it
Serving a server is a struct, an impl block, and one call:
/// A greeter: one tool, no state.
;
Greeter.into_server?.serve_stdio.await?
#[protocol_droid::server] goes on the impl block and generates the one method
that matters — into_server. The marks inside it (#[tool], #[resource],
#[prompt]) register as it goes: each parameter becomes a property in the tool's
inputSchema, which is why nothing above declares a schema, and the doc comment
becomes the description a peer reads. A #[tool] handler is async even when
it awaits nothing, because the call the macro generates has to produce a future.
Two lines are missing from the snippet above because they are not the library's
business: a struct Greeter; declaration, and the runtime. serve_stdio is
async, so main needs one — and the crate does not enable rt-multi-thread,
so it is built by hand rather than by #[tokio::main]. The full version is
examples/stdio_server.rs,
and it compiles.
Bridging a client on one revision to a server on another:
The upstream can also be a legacy HTTP+SSE server (sse:http://host/sse) or a
stateless 2026-07-28 one (http-stateless:http://host/mcp), so a
2025-11-25 client can use a server that has no handshake at all.
A server that needs a login is reached with --oauth, which signs in through
the system browser and refreshes the token when it expires:
To serve the 2024-11-05 transport beside Streamable HTTP, add .legacy_sse()
to the HttpBuilder; the same tools answer on both.
Changing a server while it serves goes through a ServerHandle. Every change
is published and then announced as notifications/*/list_changed to the
sessions that negotiated it — on stdio, on a Streamable HTTP session, or on a
2026-07-28 subscriptions/listen stream:
let server = Greeter.into_server?;
let handle = server.handle; // cheap to clone; store it anywhere
handle.add_tool.await; // listed, callable, announced
handle.notify_resource_updated.await;
At 2026-07-28 a server cannot send a request of its own, so a tool that needs
the user answers with an InputRequiredResult and reads the reply on the
client's retry. requestState is sealed by the library, so a client that edits
it is refused before the handler runs:
if let Some = ctx.input_response
new
.elicit
.with_state
.into_result
The full worked set — every SEP-2322 shape the conformance suite exercises — is
in examples/conformance_reference_server/stateless.rs.
--json puts the plan or the run report on stdout and the human summary on
stderr, so an agent parses the machine-readable half and a person watches the
other. cargo l is the lint profile; cargo test --workspace --all-features is
the suite.
The generated wire types
protocol-droid-schema turns each vendored schema.json into serde types
during build.rs, and src/wire.rs re-exports the result as one module per
revision:
use ;
// The same name, two revisions, two different shapes — both live in one build.
let old: CallToolResult =
from_str?;
let new: CallToolResult =
from_str?;
assert_eq!;
assert!; // 2024-11-05 has no such member
resultType is required in 2026-07-28 and does not exist at all in
2024-11-05, and content is a different Rust type in each. That is the whole
point: the revisions coexist as distinct types, and deciding which one a message
belongs to is a runtime question answered by negotiation, not a compile-time one
answered by a cargo feature.
One upstream oddity worth knowing about. The vendored
2025-11-25/schema.tsdeclaresLATEST_PROTOCOL_VERSION = "DRAFT-2025-v3"while its own tag is2025-11-25and every other revision'sschema.tscarries its own date. It looks like an unreleased-build leftover in a tagged revision.ProtocolVersionaccepts2025-11-25and rejectsDRAFT-2025-v3: the tag is what the specification documents and what peers negotiate on, and accepting a string the specification does not define would mean announcing a version we cannot speak. The finding is recorded in PROGRESS.md rather than papered over.
Three properties of the generated code are load-bearing, and each has a test:
- Members the type does not name are preserved. Every object type carries a
#[serde(flatten)] extra: Map<String, Value>, so a field the peer sent that this revision does not define survives a decode/encode cycle — including aresultTypefrom a newer server arriving at a2024-11-05client. That is what makes the version-translating bridge lossless rather than lossy-but-tidy. - Absence is absence, not
null. An optional member isOption<T>withskip_serializing_if = "Option::is_none", so a decode/encode cycle does not invent members the peer never sent. - A value that round-trips still satisfies the schema. The property suite
generates arbitrary values that validate against the vendored
schema.json, decodes them into the generated type, and asserts the re-encoded value is byte-identical to the original and validates again. Dropping either serde attribute above makes it fail.
The generator lives in crates/protocol-droid-schema/src/generator/. Nothing is
checked in: build.rs verifies the vendored SHA-256 digests first and only then
regenerates, so drift from the vendored bytes is a build failure rather than a
diff someone has to notice.
Toolchain
Rust stable, pinned. The MSRV and the pinned toolchain are both 1.98.1 — see
rust-toolchain.toml.
cargo l is an alias for the strict lint profile. It is the one that gates
commits; a bare cargo build is also warning-free because .cargo/config.toml
sets build.warnings = "deny".
Layout
| Crate | What it is |
|---|---|
crates/protocol-droid-schema |
Vendored official schemas, the build-time generator, and the wire types it emits |
crates/protocol-droid |
The library: IR, codecs, session engine, transports |
crates/protocol-droid-macros |
Derive macros. Proc-macro crates must be separate |
crates/protocol-droid-cli |
The protocol-droid binary |
xtask |
Developer tooling. Not published |
The vendored schemas
Every wire type is generated from the official schema.json / schema.ts, and
those files are pinned by commit and locked by SHA-256. A build refuses to
compile if the tree has drifted from the manifest, and no build ever touches the
network.
Full details — layout, what each field is for, how to vendor a new revision —
are in crates/protocol-droid-schema/spec/README.md.
Architecture
Full treatment, with diagrams, in docs/ARCHITECTURE.md.
Hexagonal, collapsed to src/{domain,application,adapters/{primary,secondary}}.
Domain code has no I/O dependencies and no traits; adapters implement port traits
declared by the application-layer handler that consumes them. Dependencies point
inward: adapters → ports ← domain.
Three rules are worth knowing before reading the code:
- The protocol-neutral core stays neutral. The JSON-RPC envelope, session
tracking, and all transports know nothing about MCP. MCP lives in
mcp/submodules within each layer. That is what lets a future protocol drop in without a refactor. - All schema revisions are always compiled. They are data, not features. A
--no-default-featuresbuild sheds transports and heavy integrations, never spec revisions. - There is no
portsmodule, and that is deliberate. Port traits belong to the consumer, so each handler declares its own beside the code that calls it. A shared ports module is how a method added for one use case ends up on every other one's interface.
Branch protection
main is protected by .github/branch-protection.json,
which follows the OpenSSF Baseline branch-protection controls. Re-apply it with:
There is no sync from GitHub. Editing protection in the settings UI works, but the next run of that script reverts it, so changes belong in the file.
| Setting | Value | Why |
|---|---|---|
| Required PR reviews | 1 | No direct commits to main |
| Stale reviews dismissed | yes | Approval does not survive new pushes |
| Conversation resolution | required | Review threads get resolved, not ignored |
| Force pushes | blocked | |
| Branch deletion | blocked | Deleting the primary branch is deliberate |
| Status checks | 9, strict | See below |
enforce_admins |
false |
Deviation — see below |
Deviation: admins can bypass.
enforce_adminsisfalse, so a repository admin can push straight tomainand bypass every rule above. That does not meet the baseline, which treats admin bypass as a finding. It is on for now because this is a solo repository and an admin-only lockout is worse than the gap. Flipping it totrueis a one-word change in the payload.
The nine status checks
They are the names CI actually reports, not the job ids in ci.yml. The test
job is a 3 × 2 matrix, so it emits six separate checks; naming a single literal
test context would leave five of them ungated while the branch looked
protected.
fmt clippy supply chain
test (ubuntu-latest, all-features) test (ubuntu-latest, no-default-features)
test (macos-latest, all-features) test (macos-latest, no-default-features)
test (windows-latest, all-features) test (windows-latest, no-default-features)
Renaming a CI job means updating this file in the same commit, or main becomes
unmergeable on a context that will never report.
Project status
The crates are at 0.3.0 and not yet on crates.io. All four version
together, so protocol-droid = "0.3" pulls matching protocol-droid-schema,
protocol-droid-macros and protocol-droid-cli.
Conformance is green on every revision the pinned suite covers — 3/3 on
2025-03-26, 55/55 on 2025-06-18, 81/81 on 2025-11-25 and 151/151 on
2026-07-28, including the scenarios the suite does not yet score. The
2026-07-28 stateless lifecycle is served in full over HTTP: per-request
_meta, server/discover, subscriptions/listen, SEP-2322 multi round-trip
requests, and the request-metadata headers. The measurements, what closed them,
and the known limitations are in
docs/CONFORMANCE-GAPS.md.
Preflight — cargo build, cargo test --workspace --all-features, cargo l
and cargo deny --all-features check — is the gate on every commit.
Releasing
The chain is:
CI (on main, green) → auto-tag.yml → tag → release.yml → crates.io
auto-tag.yml tags v<workspace version> when CI is green on a push to
main and that tag does not exist yet. release.yml runs on Auto Tag's
completion and resolves the tag from the commit; a green push that did not
create a tag is reported as "nothing to release" and every downstream job is
skipped, so bumping [workspace.package] version is what makes a release.
The upload sits behind the release GitHub environment, which must carry
required reviewers. An environment with no protection rules is a label, not a
gate — check it before trusting a green run:
A tag is created with GITHUB_TOKEN, which deliberately does not trigger
other workflows' on: push filters — which is why release.yml listens for
workflow_run rather than push: tags. A manual workflow_dispatch naming a
tag re-runs a release the trigger missed.
The docs site
The site/ directory is an Astro + Starlight
project. Develop with pnpm dev; build with pnpm build; deploy with
pnpm run deploy (the deploy script lives in site/scripts/deploy-docs.sh
and is described in site/README.md). The deploy script
verifies the live URL after pushing, and prints a fix-it line if the source
repo's Pages config is shadowing the user-pages URL — the trap that bit
the first deploy.
Licence
MIT OR Apache-2.0.