Sign inSign up

kubed/mcp-school

By kubed

•Updated 14 days ago

Image
0

834

kubed/mcp-school repository overview

⁠mcp-school

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

⁠One address space

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.

⁠Resources first, tools as a mirror

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:

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

⁠Choosing a library, or tags

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 URLheadersees
?library=grafanaX-Skill-Librarythat whole library, or one of its groups
?tags=ops,uiX-Skill-Tagsanything carrying any of the tags, across libraries
?library=grafana&tags=opsboththe 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.

⁠Prompts

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.

⁠Sources

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"]
schemebackend
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/reposhorthand 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.

⁠A WebDAV folder, and cache: live

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

⁠Configuration

variabledefaultmeaning
CONFIG/etc/mcp-school/config.yamlconfig file listing the sources to serve
CACHE_DIR/var/cache/mcp-schooldirectory a non-file:// source materialises into
TRANSPORThttphttp or stdio
HOST0.0.0.0bind address
PORT8000port

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.

⁠Index and refresh

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.

⁠Deploying

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.

⁠Development

pip install -e .[test]
pytest

⁠License

MIT

Tag summary

Content type

Image

Digest

sha256:8528fa9a7…

Size

81.1 MB

Last updated

14 days ago

docker pull kubed/mcp-school