A skills-over-MCP gateway. Ingest Agent Skills from
uploads and git repositories, then serve them over MCP using the
io.modelcontextprotocol/skills extension (SEP-2640).
Scope so far: ingest skills from archives and git repositories, curate them
into versioned bundles, and serve each bundle as its own MCP mount with its own
visibility — public, bearer, or OAuth. Depot can also federate another
skills-over-MCP server. .well-known publishing remains future work; see
ROADMAP.md.
mise install
mix deps.get
DEPOT_PORT=4100 mix run --no-halt| Variable | Default | Meaning |
|---|---|---|
DEPOT_PORT |
4100 |
HTTP listen port; none disables the listener |
DEPOT_DATA_DIR |
priv/data |
Skill store, provenance sidecars, tokens |
DEPOT_AUTH_MODE |
bearer |
bearer, oauth, or public |
DEPOT_OAUTH_ISSUER |
— | Authorization server, e.g. your Authelia URL |
DEPOT_OAUTH_AUDIENCE |
— | This Depot's public URL; checked against aud |
DEPOT_OAUTH_JWKS_URI |
issuer + /.well-known/jwks.json |
Key set |
DEPOT_SECRET_KEY_BASE |
generated and persisted | Session signing secret |
DEPOT_LOG_LEVEL |
info |
debug, info, notice, warning, or error |
DEPOT_TRUST_FORWARDED_PROTO |
false |
Trust x-forwarded-proto; set true behind TLS |
DEPOT_ALLOW_PRIVATE_TARGETS |
false |
Allow ingest from private/loopback hosts |
Behind a TLS terminator (SWAG and similar), set
DEPOT_TRUST_FORWARDED_PROTO=true so the session cookie is marked Secure —
conn.scheme is :http there, and the header is ignored by default so an
unproxied deployment cannot be told its own scheme by a client. Only the exact
string true enables it.
Deleting <data_dir>/tokens.json revokes every token: the file is the source of
truth, and a running server picks the change up on the next request. A file that
exists but cannot be read is treated as transient and does not revoke.
mix release # builds in :prod; no MIX_ENV needed
_build/prod/rel/depot/bin/depot startOr as a container:
docker build -t depot .
docker run -d --name depot -p 4100:4100 -v depot-data:/data depotMount /data and nothing else. The skill store, provenance sidecars, CAS blobs,
tokens, and the persisted secret_key_base all live there, and so does
staging/ — deliberately, because an install is a rename from staging into the
store and a rename is only atomic within one filesystem. Without a writable
volume every ingest fails at the swap, and each restart mints a fresh
secret_key_base, silently invalidating every operator session.
git must be present in the runtime image, not only at build time: repo
ingest shells out to it at request time, so an image that has it only in the
build stage builds cleanly and then fails every clone. The image ships it, and
CI asserts it is there.
The committed deployment is docker-compose.prod.yml (loopback-bound by
default; set DEPOT_BIND_LAN and DEPOT_DATA_VOLUME in .env) and, for the
public edge, ops/swag/depot.subdomain.conf.
GET /health is both the liveness and the readiness probe:
| Response | Meaning |
|---|---|
| connection refused | the process is gone |
503 {"status":"booting"} |
started, still indexing |
200 {"status":"ok"} |
the initial scan finished; the count is the catalogue served |
The listener starts before the index does, so that window is observable rather
than being a closed port an orchestrator cannot tell apart from a crash.
Depot.Store.init/1 reads and digests every file in the store before the
supervisor returns, so on a large store it is a real wait — every route but
/health answers 503 until it finishes. The image's HEALTHCHECK allows 120s;
raise --start-period for a big store.
The flag is set once, after the supervisor's initial start, and closed again on
shutdown so the drain window answers 503 rather than 500. It is not re-armed
if a child later crashes: a Depot.Store restart re-scans while /health still
reports ok. That is the ETS-ownership limit TODO.md §7 carries deliberately,
not an oversight.
Erlang distribution is bound to loopback (rel/env.sh.eex, rel/vm.args.eex).
bin/depot remote works from inside the container, which is the only place it
should be run from — the distribution port grants remote code execution to
anyone holding the cookie and is not subject to any of Depot's own scopes.
Logs are one line per entry, UTC, on stdout, for collection by the container log driver. There is no syslog backend on purpose: shipping from inside the VM would add a dependency, a socket to manage, and a second failure mode for logging.
Mint the first token on the server — with no tokens and bearer mode, nothing
can reach the UI to create one:
mix depot.token create admin --writeFrom a release or container, where Mix tasks do not exist:
docker compose -f docker-compose.prod.yml exec depot \
/app/bin/depot rpc 'Depot.Auth.Token.create("admin", ["skills:read", "skills:write"]) |> elem(1) |> IO.puts()'Two scopes. skills:read reads skills; skills:write covers ingest, deletion,
and token management. DEPOT_AUTH_MODE governs only whether an anonymous
read is allowed:
| Mode | Anonymous read | Anonymous write |
|---|---|---|
bearer (default) |
no | no |
oauth |
no | no |
public |
yes | no |
DEPOT_AUTH_MODE governs the whole-store /mcp endpoint and the UI. Bundle
mounts are governed by each bundle's own visibility, independently.
Writes always require skills:write, in every mode. A public Depot that also
accepted anonymous ingest would let any passer-by push instructions into a store
that agents act on.
Depot is always an OAuth Resource Server and never an Authorization Server. Point it at an issuer you already run:
DEPOT_AUTH_MODE=oauth DEPOT_OAUTH_ISSUER=https://auth.example.com \
DEPOT_OAUTH_AUDIENCE=https://depot.example.com mix run --no-haltIt verifies the signature against the issuer's JWKS (cached, refreshed on
unknown kid), then iss, aud, exp/nbf, and scope. Algorithms are
allowlisted, so alg: none and RS/HS confusion are rejected outright. An
unauthenticated request gets a 401 whose WWW-Authenticate points at
/.well-known/oauth-protected-resource (RFC 9728), which is how an MCP client
discovers where to get a token.
A bundle is a curated set of skills with its own mount and its own visibility. Publishing snapshots the draft: every file's digest is recorded and its bytes are copied into content-addressed storage.
POST /b/<slug>/mcp # follows the latest published version
POST /b/<slug>/v<n>/mcp # pinned to one version, forever
Skills are served as skill://<slug>/<namespace>/<name>/…. The slug takes the
URI authority component and the source namespace becomes an organizational
prefix, so two sources publishing code-review land at distinct URIs while the
skill name stays the final path segment — which is what SEP-2640 requires.
Why versions matter. SEP-2640 binds a host's per-skill approval to the
entry's whole resources set and revokes it the moment a different set appears.
A consumer mounted on /b/<slug>/v3/mcp therefore keeps its approval across
upstream pushes; one mounted on the live /mcp loses it on every re-ingest.
Publishing is how you decide when consumers get disturbed. A published version
keeps serving even if the underlying skill is later changed or deleted outright.
Each bundle carries its own visibility, so one Depot can serve a public skill
pack and a private runbook set from the same process. A token can be scoped to
a single bundle with skills:read:<slug>, which does not grant access to any
other bundle or to the whole-store /mcp endpoint.
/ lists skills; /ui/bundles creates and publishes bundles; /ui/ingest has
the repo and archive forms; /ui/tokens mints and revokes. Sign in at /ui/login by pasting a token — the browser then
rides a signed session cookie.
Deliberately plain server-rendered HTML: no asset pipeline, no client build.
It exists so ingest does not require curl, and it is meant to be replaced when
the roadmap reaches LiveView.
Skill names and descriptions come from repositories anyone can open a pull request against, so every interpolated value is HTML-escaped, and no inline handler carries interpolated text.
Clone targets must be public https:// or git@host:path. Private and
loopback addresses, and *.internal / *.local names, are refused: ingest
needs skills:write, but a write-scope credential should not double as a probe
of the network Depot sits on. Set DEPOT_ALLOW_PRIVATE_TARGETS=true when
pointing it at an internal git host.
Walk a repository for every **/SKILL.md:
curl -X POST localhost:4100/ingest/repo -H "authorization: Bearer $DEPOT_TOKEN" -H 'content-type: application/json' -d '{"url":"https://github.com/cloudflare/skills","namespace":"cloudflare"}'Read what a domain publishes at /.well-known/agent-skills/index.json
(Cloudflare's Agent Skills Discovery RFC):
curl -X POST localhost:4100/ingest/well-known -H "authorization: Bearer $DEPOT_TOKEN" -H 'content-type: application/json' -d '{"domain":"developers.cloudflare.com"}'Unlike a git clone, this format ships a SHA-256 digest for every artifact and the RFC requires the client to verify it. Depot refuses any artifact that does not match, rather than installing bytes the index does not vouch for.
Walk a Claude Code plugin marketplace — a repository with
.claude-plugin/marketplace.json — and ingest the skills its plugins ship:
curl "localhost:4100/search/marketplace?source=jmagar/dendrite" -H "authorization: Bearer $DEPOT_TOKEN"
curl -X POST localhost:4100/ingest/marketplace -H "authorization: Bearer $DEPOT_TOKEN" -H 'content-type: application/json' -d '{"source":"jmagar/dendrite"}'This is the widest source: one marketplace points at skills across many
repositories. A plugin's source takes four shapes — a path inside the
marketplace repo, github + repo, url, or git-subdir + path — and all
four resolve to a git target handed to the same clone-and-walk as
/ingest/repo. sha is preferred over ref, because a branch moves.
Plugins vendored in the marketplace repository share a single clone rather than
one per plugin. Each plugin gets its own namespace, so two plugins shipping
code-review cannot overwrite each other; when two names sanitise to the same
namespace the first wins and the rest are reported. {"only": ["a","b"]}
narrows the walk.
Most plugins ship no SKILL.md at all — they are commands, hooks, or MCP
servers — so the result separates installed, skipped, and failed. A plugin
with no skills is skipped, not failed.
Search skills.sh and ingest what it points at:
curl "localhost:4100/search/skills-sh?q=terraform" -H "authorization: Bearer $DEPOT_TOKEN"skills.sh is a registry of pointers rather than artifacts — a result names a GitHub repository — so ingesting one hands off to the repository walk above.
Upload a .tar.gz, .tgz, .tar, or .zip containing one skill:
curl -X POST localhost:4100/ingest/archive -H "authorization: Bearer $DEPOT_TOKEN" -F "file=@my-skill.tar.gz" -F "namespace=uploads"The MCP endpoint is POST /mcp (Streamable HTTP). Send an
MCP-Protocol-Version header — it selects the lifecycle:
| Version | Lifecycle |
|---|---|
2026-07-28 |
server/discover returns supported versions, capabilities, and instructions. initialize and ping do not exist; results carry resultType and _meta; an unknown method is a 404 |
2025-11-25 |
initialize negotiates in the body; ping works |
A request with no header is accepted only if it is an initialize; anything
else is answered with the list of versions Depot speaks.
Skill methods are the same on both: skills/list, skills/get,
resources/read, resources/list, and resources/directory/read. The
extension is declared as:
{"capabilities":{"extensions":{"io.modelcontextprotocol/skills":{"directoryRead":true}}}}Skill URIs are skill://<namespace>/<name>/<file>. The namespace occupies the
RFC 3986 authority component and the name is the final path segment, which is
what SEP-2640 requires and what keeps two sources publishing code-review from
colliding.
GET /health and GET /skills are available for inspection.
Skills are files, so the filesystem is the source of truth. There is no
database. The index — frontmatter and per-file SHA-256 digests — lives in ETS and
is rebuilt by scanning <data_dir>/skills at boot and after each ingest.
Published bundle versions instead read from <data_dir>/cas, keyed by the same
sha256: digest the protocol reports, so identical files across sources and
versions cost one copy.
Content is never mutated. Bytes in are bytes out. Provenance is written to a
sidecar tree at <data_dir>/origins, never into the skill directory: a file
added there would join the skill's file set and its resources digest list,
making the skill Depot serves differ from the one upstream published.
Depot never executes skill content. It serves bytes. Every code-execution risk in the skills threat model stays with the consuming host, which is where SEP-2640 puts it.
Digests are not a security boundary. They are unsigned and supplied by the
same origin as the content, so a match proves the listing and the bytes agree —
not that either is trustworthy. The WG threat model names a gateway as
adversary #2, "the rewriting intermediary", and that applies to this one. What
Depot offers instead is an auditable provenance record, shaped to
ARD's trustManifest so a signed
attestation can slot in later.
Archive uploads are hardened. SEP-2640 removed archive distribution because
"unpacking is an attack surface disproportionate to the benefit". Depot accepts
archives only as an operator upload, but the mechanics are identical, so
Depot.Ingest.Archive implements the threat model's Appendix A checklist:
traversal, Windows and drive-absolute paths, symlink and hardlink escape,
non-regular entries, setuid bits, size and entry-count caps enforced from the
archive table before extraction, and case/Unicode normalization collisions.
test/depot/ingest/archive_test.exs exercises each one.
A skill whose frontmatter name breaks the Agent Skills naming rule — "Max 64
characters. Lowercase letters, numbers, and hyphens only. Must not start or end
with a hyphen." — is refused rather than installed. SEP-2640 requires the final
segment of a skill's URI to equal its frontmatter name, and that segment must
be a valid RFC 3986 reg-name, so a capitalised name cannot be served
conformantly under any URI Depot could mint.
This is not hypothetical: agentskills.io publishes one skill at its own
.well-known index whose name is Agent, and Depot refuses it.
Verified against fast-agent 0.9.30, the one shipped MCP host that speaks
this extension. It discovers skills by reading the reserved
skill://index.json resource — the previous SEP-2640 design, which
skills/list and skills/get replaced. Depot serves that index for
compatibility alongside the current methods, so both revisions work.
Without it, fast-agent sees the extension advertised, reads the index, gets an
error, and registers zero skills. Its own parser and digest/frontmatter
verification accept Depot's index and all thirteen skills of
cloudflare/skills.
SEP-2640 is a Draft whose canonical text moves. Depot targets the revision
fast-agent pins — d7490ecd1a250f7bc8c3ebb0d65450dfec274bad — so the two
interoperate. GET /health reports it.
TODO.md is the complete list, including the limits carried on purpose and the
things that have never been run once — no agent has yet loaded a skill from
Depot and acted on it, and OAuth has never met a real identity provider.
Reclaim content-addressed blobs no published version references:
mix depot.gcmix testmix check