-
Notifications
You must be signed in to change notification settings - Fork 2
apps mcp server
Active contributors: Nik Anand
skill-registry-mcp is a FastMCP stdio server that exposes the user's GitHub-backed registry as three MCP tools. Desktop clients (Claude Desktop, Cursor, VS Code/Copilot) spawn it as a subprocess; the agent calls list_skills, get_skill, or publish_skill and the server proxies through to GitHub via the user's authenticated gh CLI.
The entire server is one Python file: src/skills_mcp/registry_server.py. The Git Data API client it delegates to lives in src/skills_mcp/registry_api.py. There is no embedded HTTP client, no git shell-out, and no SSH dependency — desktop MCP clients spawn the subprocess with a stripped environment, so every assumption baked in has to survive without PATH extensions or SSH_AUTH_SOCK.
build_server() runs three checks before returning the FastMCP instance:
-
load_config()reads~/.config/skills-mcp/registry.toml(or$SKILLS_REGISTRYif set). Missing or malformed config raisesConfigError. -
ensure_authed()locatesghviafind_gh()(PATH + curated fallback dirs) and runsgh auth status. Missing binary raisesGhNotFoundError; unauthenticated session raisesGhNotAuthedError. -
RegistryClient(repo=…, default_branch=…)is constructed and itsghattribute is set to the already-resolved path so later calls skip re-lookup.
The FastMCP constructor itself receives name="skill-registry", an instructions blurb pointing at the configured repo, and version=__version__ (resolved from installed package metadata via importlib.metadata).
Boot-time failures are surfaced to the desktop client as exit codes — never as silent retries. See exit codes below.
Each tool is registered through the @server.tool(...) decorator inside _register_tools(server, client, repo). Three tools, three annotation profiles:
| Tool | readOnlyHint |
destructiveHint |
openWorldHint |
Returns |
|---|---|---|---|---|
list_skills |
✓ | — | ✓ | Markdown table (slug / name / description). |
get_skill |
✓ | — | ✓ | Absolute path on disk to the cached folder. |
publish_skill |
— | ✓ | ✓ |
"Published \` to @. View: …"`.
|
The annotations are surfaced to MCP clients so they can gate destructive operations behind explicit user approval. openWorldHint=True on all three signals that the tool touches the network.
Lists every skill via client.list_skills() and formats a markdown table. Empty registries return "No skills found in <repo>.". Pipe characters and newlines in descriptions are escaped (| → \|, \n → space) so the rendered table stays well-formed.
Implements a tree-SHA-aware cache:
-
client.get_folder_sha(slug)returns the current SHA of the<slug>/subtree, orNoneif missing. -
cache.lookup(slug)returns the cached path + recorded SHA, orNoneon miss. - If both SHAs match → return the cached path immediately (cache hit).
- Otherwise wipe the cached dir via
cache.reserve(slug), callclient.download_skill(slug, dest), thencache.commit(slug, current_sha)to record the new SHA in<slug>.meta.json.
Force-pushes invalidate correctly because the SHA changes. See systems/registry-client and src/skills_mcp/cache.py.
Accepts name (required) plus exactly one of files (a dict of relative paths → text content) or local_folder (an absolute path containing SKILL.md). Passing both or neither raises ValueError.
The control flow:
-
slugify(name)produces the canonical slug. - If
fileswas passed, every key is run through_normalize_rel_pathand every value is encoded to bytes via_encode_text(rejects non-strings with aTypeError). - If
local_folderwas passed,_collect_local_folder(folder)walks the tree, skipping hidden entries (any path component starting with.) and__pycache__. -
SKILL.mdmust be present at the root of the payload, or the call raisesValueError. -
_validate_size(payload)rejects any file larger than_MAX_FILE_BYTES. -
client.publish_skill(slug, payload)runs the six-call Git Data API sequence (GET ref → GET commit → GET trees → POST blobs → POST trees → POST commits → PATCH ref) with retries on 409/422.
_normalize_rel_path(raw):
rel = raw.replace(os.sep, "/")
while rel.startswith("./"):
rel = rel[2:]
rel = rel.lstrip("/")
if ".." in rel.split("/"):
raise ValueError(f"Refusing path with '..' segments: {raw!r}")
return relThree layers of defense:
-
os.sep → /so backslash-encoded traversals on Windows can't sneak through. - Leading
./stripped iteratively so././../etcnormalizes correctly. - Leading
/stripped so absolute-path injection ("/etc/passwd") becomes"etc/passwd". - After splitting on
/, any segment equal to..is rejected outright.
_collect_local_folder additionally skips any path with a dotfile component anywhere in the tree, so .git/HEAD and .DS_Store never reach the registry. The same hardening is mirrored on the Go side in cli/internal/registry/registry.go.
_MAX_FILE_BYTES is 2 * 1024 * 1024 (2 MiB) by default. Override with the SKILLS_MAX_FILE_BYTES environment variable:
_MAX_FILE_BYTES = int(os.environ.get("SKILLS_MAX_FILE_BYTES", str(2 * 1024 * 1024)))_validate_size(payload) checks every file before the publish call goes out and raises ValueError listing the offending file's byte count and the cap. The Go CLI's publish.go:collectFiles enforces the same 2 MiB ceiling.
main() configures the root logger from $SKILLS_LOG_LEVEL (default INFO), writing to stderr in a %(asctime)s %(levelname)s %(name)s: %(message)s format. The server's own logger is skills_mcp.registry_server; the API client logs under skills_mcp.registry_api. Stderr is the only safe sink because stdio is the MCP transport.
main() translates boot failures into a small, stable set of process exit codes so desktop clients can show targeted error UI:
| Code | Triggered by | Meaning |
|---|---|---|
0 |
clean stdio close | normal exit |
2 |
ConfigError |
~/.config/skills-mcp/registry.toml is missing, malformed, or has no repo
|
3 |
GhNotFoundError |
gh not found on PATH or in the curated fallback dirs |
4 |
GhNotAuthedError |
gh is present but gh auth status failed |
Once server.run() is reached, the FastMCP runtime owns the lifecycle and any tool-call error is returned to the client as an MCP error response rather than a process exit.
| File | Role |
|---|---|
src/skills_mcp/registry_server.py |
build_server(), tool registration, path-traversal hardening, main() with exit codes. |
src/skills_mcp/registry_api.py |
RegistryClient — gh-api wrapper, atomic Git Data API publish/delete, retry on 409/422. |
src/skills_mcp/gh.py |
find_gh(), ensure_authed(), gh_api() — every GitHub call goes through these. |
src/skills_mcp/config.py |
TOML config read/save; honors $SKILLS_REGISTRY override. |
src/skills_mcp/cache.py |
lookup() / reserve() / commit() with <slug>.meta.json tree-SHA storage. |
src/skills_mcp/frontmatter.py |
parse_frontmatter + first_paragraph — YAML-ish parser, no PyYAML. |
src/skills_mcp/init.py |
Legacy skills-registry init console script (still in wheel for back-compat). |
- overview/architecture — the two upload paths and why they differ.
- overview/getting-started — how to wire the server into Claude / Cursor / Codex / VS Code.
-
systems/registry-client —
RegistryClientdeep dive (Python and Go). - api/mcp-tools — full tool signatures, argument schemas, and return shapes.