Skip to content

Repository files navigation

Depot

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.

Run

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 Secureconn.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.

Deploy

mix release          # builds in :prod; no MIX_ENV needed
_build/prod/rel/depot/bin/depot start

Or as a container:

docker build -t depot .
docker run -d --name depot -p 4100:4100 -v depot-data:/data depot

Mount /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.

Health and readiness

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.

Auth

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 --write

From 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-halt

It 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.

Bundles

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.

UI

/ 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.

Ingest

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"

Consume

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.

Design notes

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.

Skills that cannot be served

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.

Host compatibility

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.

Spec target

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.

What is not built yet

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.

Test

Reclaim content-addressed blobs no published version references:

mix depot.gc
mix test
mix check

About

Skills-over-MCP gateway: ingest Agent Skills, curate them into versioned bundles, serve each as its own authenticated MCP mount

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages