A resource and prompt gateway for MCP. Serves Agent Skills and prompts over MCP, so any client can discover and read them — including clients that only speak tools.
The image bakes nothing. A source is a dependency, declared in a config file and
fetched at container start — see examples/config.yaml, the four packs this
image used to bake, now read straight from GitHub:
docker run -p 8000:8000 -v mcp-school-cache:/var/cache/mcp-school \
-v $PWD/examples/config.yaml:/etc/mcp-school/config.yaml:ro \
kubed/mcp-school:latest
Everything this server serves is a skill:// URI, and reading one is the only
operation there is:
skill://<pack> an index: every skill in a pack
skill://<group> an index: every skill in a group
skill://<pack>/<skill> that skill's instructions
skill://<pack>/<skill>/_manifest what else it ships
skill://<pack>/<skill>/<path> one of those files
skill://<pack>/_files files the pack ships outside its skills
skill://<pack>/<path> one of those
One segment is an index, two or more is content. That is the whole grammar.
Progressive disclosure lives in the address space rather than in a tool list, so the listing stays small no matter how many skills are installed:
list → 12 indexes, ~1.9 KB
read skill://grafana-lgtm → 6 skills, as URIs
read skill://grafana/loki → the instructions to follow
Currently served: 90 skills across four packs.
MCP has a primitive for material an agent reads, and it is the resource. So resources are the interface, and a client that speaks them sees no tools at all.
Many clients only implement tools — n8n's MCP Client Tool is one — and to those a resource-only server looks empty. They get the same interface as two tools:
| tool | mirrors |
|---|---|
list_resources() | resources/list — the same uri/name/description/mimeType rows |
read_resource(uri) | resources/read — the same URI |
list_prompts() | prompts/list — each prompt's name, description and arguments |
get_prompt(name, arguments) | prompts/get — the rendered, role-tagged messages |
Each pair is turned on separately, because a client may have one feature and not the
other — ?resources=off, ?prompts=off, or both, on the MCP URL (or the
X-MCP-Resources / X-MCP-Prompts headers):
http://mcp-school.flow.svc.cluster.local:8000/mcp?resources=off&prompts=off
The tools are hidden from clients that have the real feature, because advertising both shapes is two ways to ask one question. They stay callable either way.
A client can be narrowed to part of the catalogue, and the narrowing is a ceiling the model cannot widen past — enforced on resources, prompts and every mirror tool:
| on the MCP URL | header | sees |
|---|---|---|
?library=grafana | X-Skill-Library | that whole library, or one of its groups |
?tags=ops,ui | X-Skill-Tags | anything carrying any of the tags, across libraries |
?library=grafana&tags=ops | both | the tagged part of that one library |
A header beats the URL. X-Skill-Pack still works as an alias for X-Skill-Library.
Set it once in the client's connection config — in n8n, a Header Auth credential on the
MCP Client Tool node, a plumbed constant rather than something the model fills in.
A deployment that should serve less gets a config that lists less.
A prompt is a template a person picks and fills in before the model sees anything —
Claude Code lists them as slash commands. A source serves them like anything
else it ships: prompts/**/*.md by convention, or an explicit include.prompts
glob, named <pack>_<file-stem>.
Each is YAML frontmatter declaring its arguments, then a body with {{ placeholders }}:
---
description: Debug a workload's recent logs in Loki with the Grafana MCP server.
arguments:
- name: app
required: true
- name: since
default: 1h
---
Investigate the logs of **{{ app }}** over the last {{ since }}.
Double braces, because prompt bodies are full of LogQL and JSON. A library or tag scope applies to prompts exactly as to skills, and a library holding only prompts is still selectable by name.
A source is a dependency, declared like one in the config file:
sources:
- name: superpowers
url: github://obra/superpowers
ref: b36e0829c6d0140e93cfef2ca599b1b07d4a7797
include:
skills: ["skills/*/SKILL.md"]
| scheme | backend |
|---|---|
file:// | a directory on this machine, served in place — must be absolute (file:///path) |
git+https://, git+http://, git+file:// | a git remote, cloned bare and shallow |
github://org/repo | shorthand for git+https://github.com/org/repo.git |
webdav+https://, webdav+http:// | a WebDAV folder — Nextcloud above all — copied into the cache |
ref is a branch, tag or commit; pin a commit and an image is reproducible, leave
it unset and the source tracks the remote's default branch. subdirectory narrows
the harvest to a path within the clone — the shipped example reaches the same
effect with include globs like skills/*/SKILL.md instead, without setting one.
auth supplies HTTP Basic credentials for a private remote as {username, password: {env: NAME}} — GitHub wants x-access-token and a GITHUB_TOKEN-style PAT. The
username takes an {env:} reference too, for the common case where a service
account's name is issued alongside its password and repeating it here is how the
two drift apart. It is
the only way in: a URL carrying its own user:token@ is refused, because git saves
a remote URL to disk verbatim and the URL is quoted back in what /health reports.
One difference between the backends worth knowing: a symlink in a git repository is
served as a regular file holding the link's target as its text. Behind file:// a
symlink is followed when it lands inside the source root — which is what makes a
Kubernetes ConfigMap mount, all symlinks, servable — and left out when it escapes.
A pack that factors shared material up out of its skills — penpot references shared/*
from 190 places — adds those directories to include.files, and they are served at
skill://<pack>/<path> alongside the skills that cite them.
A git source is cloned once and re-exported on its refresh interval (30s, 5m,
1h); left unset, it is read once at boot and only rebuilt on restart.
cache: liveThe URL is the folder, so a Nextcloud share is the path you see in its web UI
under remote.php/dav/files/<user>/. auth is required — WebDAV has no useful
anonymous mode — and the password is an {env:} reference to a Nextcloud app
password, never the account's own:
- name: notes
url: webdav+https://cloud.example.com/remote.php/dav/files/drk/Skills
auth:
username: {env: NEXTCLOUD_USER} # or the literal name
password: {env: NEXTCLOUD_PASSWORD}
cache: live
refresh: 5m
include:
skills: ["*/SKILL.md"]
cache is the dial. snapshot, the default, is every other mirrored source: the
folder is copied at index time 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.
What live mode does not do is notice a new file. One that did not exist when
the folder was indexed has no URI, so nothing ever asks to read it — and the same
goes for a renamed or deleted one. Nor does it refresh a skill's description:
listing rows come from the harvest, so an edited description appears when the
source is rebuilt. That is what refresh: 5m above is for, and POST /reindex
forces it now. Live mode is for the bodies of the files that are already there.
A server that stops answering is not an outage: a revalidation that fails is logged
and the read is served from the copy on disk. A server that has gone quiet rather
than refused — accepting connections and never replying — costs one read a bounded
timeout, and that source is then left unrevalidated for half a minute, so the reads
behind it come straight off disk. /health marks such a source cooling, and it
comes back by itself as soon as the server answers again.
One caveat if the folder is not Nextcloud's. Revalidation is only as sharp as the
server's ETag — Nextcloud derives one from the content, but a server that derives it
from mtime and size can miss an edit that changed neither, and one that sends no
ETag at all falls back to exactly that pair. cache: snapshot plus a refresh
interval is the honest setting there.
| variable | default | meaning |
|---|---|---|
CONFIG | /etc/mcp-school/config.yaml | config file listing the sources to serve |
CACHE_DIR | /var/cache/mcp-school | directory a non-file:// source materialises into |
TRANSPORT | http | http or stdio |
HOST | 0.0.0.0 | bind address |
PORT | 8000 | port |
See examples/config.yaml for what the image ships, and config.schema.json — a
plain Config.model_json_schema() — for the full shape of a source.
Per request: ?resources=off and ?prompts=off reveal the tool mirrors, ?library=
and ?tags= narrow what is served, and ?skills=full enumerates every skill in the
listing (for clients that sync skills to disk).
GET /health reports status, the libraries and skill/prompt counts, and each
configured source's own status.
CACHE_DIR/index.json is the on-disk catalogue: what each source yielded last
time, rows enough to rebuild every skill and prompt without re-reading a
single file. Cold start reads it and serves in milliseconds; a background pass
then checks each source's fingerprint and rebuilds only the ones that moved,
so a pod restart never has to re-harvest a source that has not changed.
refresh: 5m on a source schedules that check; a source with none is never
revisited on its own:
- name: superpowers-tip
url: github://obra/superpowers
ref: main
refresh: 5m
POST /reindex forces a rebuild of every source immediately, fingerprint
check skipped, and answers with /health's body plus rebuilt, the source
names actually rebuilt this pass — since the fingerprint check is skipped,
that is every source that did not fail identically to how it already had,
changed or not, not only the ones whose content moved.
A session that persists across requests is told, once, the next time it asks
— MCP 2026-07-28 has no sessions of its own, so a sessionless client gets no
such notice and instead relies on cache_ttl, which this server advertises as
the shortest refresh among its sources.
The image is the whole artifact: point CONFIG at a config file and CACHE_DIR at a
writable volume, and it serves whatever the config names. Kubernetes manifests, node
placement and everything else about running this somewhere are the installer's
concern — this repo ships none of its own.
CACHE_DIR has to be writable by uid 65534, which the container runs as. A Docker
named volume inherits the directory's ownership and needs nothing; a Kubernetes
emptyDir or PVC mounts root-owned, so the pod needs securityContext.fsGroup: 65534.
Without it the server logs that it cannot write index.json and re-harvests every
source on every restart.
pip install -e .[test]
pytest
MIT
Content type
Image
Digest
sha256:8528fa9a7…
Size
81.1 MB
Last updated
14 days ago
docker pull kubed/mcp-school