Skip to content

Sources

Kelly Ferrone edited this page Sep 21, 2026 · 9 revisions

Sources

A source is a backend, named once: where bytes come from and how to log in. It is the only place a credential is written, however many plugins read through it. What is served out of one is Plugins, grouped into Libraries; every field is in Configuration.

sources:
- name: github
  url: git+https://github.com
- name: nextcloud
  url: webdav+http://cloud.example.com/remote.php/dav/files
  auth:
    username: {env: NEXTCLOUD_USER}
    password: {env: NEXTCLOUD_PASSWORD}
  cache: live
  refresh: 5m

How an address joins a source

A plugin's source — and a marketplace library's — is an address whose scheme is a source's name:

nextcloud://mcp-kb/ai          the ai folder of the mcp-kb account, over WebDAV
github://grafana/skills?ref=…  that repository at that commit
file:///srv/prompts/grafana    a directory on this machine

The source's url is the base and the address's path completes it, so github://grafana/skills is https://github.com/grafana/skills and nextcloud://mcp-kb/ai is that folder under …/remote.php/dav/files. file:// is built in and needs no source entry: a local directory carries no credential and nothing to configure.

Nothing about a backend reaches what is served. A cache path, a clone directory, a WebDAV URL and a source's name are config; a URI, a listing row, a prompt name and a served body are not.

A fetch is one URL at one ref

Whatever the backend, what a source names becomes a directory on this machine before anything reads it, and that materialised tree is a fetch. Its identity is the address without its subdirectory — the path and the ?ref= — so two plugins in one repository at one commit are one clone, one export and one entry in /health.

That is what makes declaring your own plugin beside somebody's marketplace free: same URL, same ref, same fetch.

git+… — a repository at a ref

- name: github
  url: git+https://github.com
URL Is
git+https://host any git remote over HTTPS
git+http://host the same, unencrypted
git+file:///path repositories on this machine

The repository is cloned bare and shallow into the cache — no working copy, nothing writable, and git+file:// clones whole because libgit2's local transport refuses a shallow fetch. The ref on the address is resolved to a commit and that commit's tree is exported to its own directory, so a refresh adds a tree rather than overwriting the one being served.

A symlink in a git repository is exported as a regular file holding the link's target as its text, because an export has no way to recreate links. The same tree behind file:// resolves the link instead. Neither escapes its root.

Pinned versus floating

?ref= on the address is a branch, a tag or a 40-character commit sha. Left off, the address tracks the remote's default branch.

  • A pinned sha never touches the network to answer "has it moved?" A commit cannot move, so there is nothing to ask. The example config pins all four of its libraries and gives its source no refresh: for exactly that reason.
  • A branch or a tag costs one ref listing per check. That is the cheapest question git has — no fetch, no objects — and only a commit that turned out to have moved downloads anything.

Pin a sha when you want an image that serves the same bytes in a year. Track a branch when you want the tip, and give the source a refresh: so something goes and looks.

A private remote

- name: github
  url: git+https://github.com
  auth:
    username: x-access-token
    password: {env: GITHUB_TOKEN}

GitHub wants the literal x-access-token as the username and a token as the password. The username also takes an {env:} reference, for the common case where a service account's name is issued in the same secret as its password and writing it out twice is how the two drift apart.

The credential is resolved at the moment of the call and nowhere else. It is the only way in: a URL carrying its own user:token@ is refused outright, because git writes a remote URL into the clone's config verbatim and that URL is quoted back in what /health reports.

webdav+… — a folder somebody edits

- name: nextcloud
  url: webdav+http://cloud.example.com/remote.php/dav/files
  auth:
    username: {env: NEXTCLOUD_USER}
    password: {env: NEXTCLOUD_PASSWORD}

The URL plus a plugin's path is the folder — for Nextcloud, what you see in the web UI under remote.php/dav/files/<user>/. auth is required: WebDAV has no useful anonymous mode, so an omitted credential is a typo rather than a choice, and it is refused at load. Use a Nextcloud app password, never the account's own.

A failure says which: the folder /remote.php/dav/files/mcp-kb/ai does not exist, HTTP 401: the credentials were refused, or HTTP 403: the account may not read this folder. A refusal while a fetch is first built stops the server — see Deployment.

The folder is priced with one recursive PROPFIND and the digest of its ETags names the copy, so a folder that has not changed resolves to the export already on disk and is not downloaded again.

file:// — a directory here

plugins:
- name: house-prompts
  source: file:///srv/prompts/grafana
  prompts: ["*.md"]

Served in place; nothing is copied, and no source entry is needed. The path must be absolute: file:///path, three slashes. file://relative/path fills the host slot instead and is refused.

A symlink is followed when it lands inside the root and ignored when it escapes — which is what makes a Kubernetes ConfigMap mount, all symlinks, servable. The path a client sees is the one the config asked for, not the ..<timestamp>/ directory a mount resolves to.

A source over a local base path — url: file:///srv/skills, under a name of its own — is allowed and occasionally worth it: it gives several plugins under one directory a shared base and a refresh:. The name file itself is refused, because that scheme is built in and nothing could ever address such a source.

cache: snapshot versus cache: live

snapshot is the default and is how every mirrored backend behaves: the tree is copied when it is built and read from disk thereafter, so a request touches no network at all.

cache: live keeps that copy and revalidates a file as it is read — one PROPFIND for its ETag, and a download only if the ETag moved. A file edited in Nextcloud is served on the next read, with no refresh and no restart. It needs a WebDAV source; on git or a local directory it would be a dial that does nothing, and it is refused at load.

What live mode does not do:

  • It cannot see a new file. One that did not exist when the tree was harvested has no URI, so nothing ever asks to read it — and the same goes for a renamed or deleted one.
  • It does not refresh a description. Listing rows come from the harvest, so an edited description: appears when the fetch is rebuilt.

Both of those are what refresh: is for, and POST /reindex forces one now. Live mode is for the bodies of the files that are already there.

Revalidation is only as sharp as the server's ETag. Nextcloud derives one from the content; a server that derives it from mtime and size can miss an edit that changed neither. cache: snapshot plus a refresh: interval is the honest setting there.

refresh — going back to look

refresh: 30s    # or 5m, or 1h

The interval on which the fetches under this source are examined. A source with no refresh: is read once at boot and never revisited on its own; POST /reindex is the manual lever.

Examining is cheap and rebuilding is not, so they are separate: the check takes a fingerprint — the exported commit for git, the ETag digest for WebDAV, a file count, byte total and newest mtime for file:// — and only a fingerprint that moved triggers a harvest. A fetch checked every 30s that has not changed in a month does no work at all beyond the check.

A file:// fingerprint counts the catalogue and manifest files as well — marketplace.json and plugin.json, wherever the conventions put them — so a marketplace mounted from a volume is re-read on the same interval when you edit it, even though those files are never themselves served.

The shortest refresh: among all sources is also what the server advertises as its listing cache TTL. See Operations.

When a fetch goes wrong

A fetch that fails does not fail the server. It is reported in /health under its own key, in one of two states, and the difference matters:

  • stale — it materialised successfully at some point and a later refresh failed. The last good tree is still being served, built and fingerprint still describe it, and error says what went wrong. A momentarily unreachable git remote must not empty a catalogue that was complete a minute ago.
  • failed — nothing was ever materialised. There is no tree; only the error, on the fetch and on every plugin that reads it.

A stale fetch clears itself the next time a rebuild succeeds. Neither state changes the HTTP status code, which is 200 either way — see Operations for why a liveness probe must not read the body.


Home · Config file · Plugins · Configuration · Operations

Clone this wiki locally