Skills Registry
by ashwnn
README.md
# Skills Registry
A personal, read-only MCP skill registry: one GitHub repository, one
Cloudflare Worker, four stable MCP tools. Every approved push builds and
deploys a validated snapshot; every client keeps the same endpoint URL.
## Architecture
Deployment-time publishing, not runtime GitHub fetching:
```mermaid
flowchart TD
G["GitHub repository"] --> V["CI validation and snapshot build"]
V --> D["Deploy Worker at stable URL"]
C["AI clients"] --> A["MCP authentication"]
A --> W["Worker: four registry tools"]
D --> W
W --> S["Bundled index and skill files"]
V --> F["Validation failure: retain existing deployment"]
```
The Worker embeds a generated catalog (`src/generated/catalog.json`) built
from one exact Git commit. It answers MCP requests with no GitHub API
calls, no database, and no session state — see `src/worker.ts`.
## Repository layout
| Path | Purpose |
| --- | --- |
| `skills/<id>/SKILL.md` | Metadata (YAML frontmatter) + primary instructions |
| `skills/<id>/references/`, `templates/` | Supporting Markdown/text, explicitly allowlisted per skill |
| `skills/<id>/scripts/` | Supporting shell/Python/JS/TS source, same allowlist rules, never executed by the registry |
| `registry.config.json` | Schema, allowed file types, size limits, search/pagination defaults |
| `src/` | Worker, GitHub token check, MCP dispatch, tools, search, registry loader |
| `scripts/` | `validate-skills.mjs`, `build-snapshot.mjs` |
| `tests/` | Contract, search, retrieval, revision, security, auth tests |
| `docs/client-setup.md` | Per-client connection steps and the shared bootstrap instruction |
| `.github/workflows/` | `validate.yml` (PRs + push), `deploy.yml` (push to main) |
`src/generated/` is a build output (gitignored), regenerated by CI from the
exact checked-out commit. It is never a second source of truth.
## Authoring a skill
1. Create `skills/<id>/SKILL.md` with frontmatter:
```yaml
---
schema_version: 1
id: your-skill-id
name: Human Readable Name
description: One sentence, used in search and listings.
version: 1.0.0
tags: [development]
keywords: [relevant, terms]
requires: [] # capabilities the client needs; does not grant them
files: [] # explicit retrieval allowlist, relative to the skill dir
required_zdr: false # true = only served to callers cleared for Zero Data Retention (see "Data handling")
---
# When to use
...
# Instructions
...
```
2. `id` must be a stable lowercase slug matching the directory name
(`^[a-z0-9]+(-[a-z0-9]+)*$`) — treat renaming it as a breaking change.
3. List every supporting file under `files`; anything on disk that isn't
listed fails validation, and anything listed that doesn't exist also
fails.
4. Bump `version` (semver) whenever the body or a supporting file changes —
CI computes a content hash against the base branch and fails if the
version didn't move.
5. Run `npm run validate` locally before pushing.
6. If a skill's instructions genuinely need a local script, put it under
`scripts/` (`.sh`, `.py`, `.js`, `.ts`, `.rb` — see `registry.config.json`
`allowed_script_extensions`) and declare it in `files` like any other
supporting file. It is served as plain text via `skills_get_file` with
`executable: true`; the registry never runs it. State plainly in the
skill's own instructions that the client must read the script before
running it under its own local execution permissions.
7. If a skill's content must never leave a Zero-Data-Retention-cleared
provider, set `required_zdr: true`. See "Data handling" below — this is
an access-control decision, not a content-type decision, so it applies
regardless of file type.
## Local development
```
npm install
npm run validate # metadata, duplicate IDs, file allowlist, size, version bumps
npm run build # generates src/generated/catalog.json from the working tree
npm run typecheck
npm test # unit tests against fixture catalogs, independent of build output
npm run dev # wrangler dev, serves the just-built snapshot locally
```
## MCP interface
Four tools, all read-only/non-destructive, all returning `api_version`,
`registry_sha`, and `published_at`:
| Tool | Purpose |
| --- | --- |
| `skills_list` | Paginated metadata, optional `tags`/`limit`/`cursor`/`revision` |
| `skills_search` | Ranked keyword/tag search with match reasons and excerpts |
| `skills_get` | Metadata, primary instructions, supporting-file manifest for one skill |
| `skills_get_file` | Bounded, paginated text from one allowlisted supporting file |
`revision` means the registry's Git SHA, not a skill's semantic version. An
omitted revision targets the active snapshot; a stale one returns
`REVISION_UNAVAILABLE` naming the active SHA so a client restarts discovery
instead of mixing revisions. Application errors (`SKILL_NOT_FOUND`,
`FILE_NOT_FOUND`, `INVALID_PATH`, `REVISION_UNAVAILABLE`, `INVALID_CURSOR`)
come back as a normal tool result with `isError: true`, never disguised as
an empty search result. Malformed arguments are JSON-RPC `-32602` errors.
Search is deterministic keyword/tag matching (`src/search.ts`): exact
id/name match ranks highest, then tag, then keyword, then description
substring, tie-broken by id. No embeddings, no invented relevance.
Every skill's metadata carries `required_zdr` (see "Data handling"), and
every file manifest entry carries `executable` (true for anything under
`scripts/`). A caller not cleared for `required_zdr` skills doesn't see
them at all — `skills_list`/`skills_search` omit them, `skills_get`/
`skills_get_file` return `SKILL_NOT_FOUND` rather than a permission error,
so their existence isn't leaked to callers who can't read them.
## Authentication
**Decision: direct GitHub personal-access-token validation, no OAuth App,
no redirect flow.** A client sends a GitHub PAT as its bearer token; the
Worker asks GitHub who it belongs to (one call to `api.github.com/user`,
see `src/github.ts`) and checks the returned login against
`ALLOWED_GITHUB_LOGINS`. See `src/worker.ts`'s `resolveCaller`.
This replaced an earlier full OAuth-provider design (GitHub OAuth App +
`/authorize` + `/github/callback` + `@cloudflare/workers-oauth-provider`
issuing its own per-client tokens). For a single-operator personal registry
where every client can be configured with a static bearer/API-key header
(Claude Code, Codex, OpenCode all support this), that was more machinery
than the problem needed: no OAuth App to register, no callback URL, no KV
namespace for grant storage, no DCR/CIMD/PKCE surface to maintain. The
tradeoff is Claude Web / ChatGPT Web connectors, which are generally built
around an interactive "Authorize" redirect button rather than a pasted
token field — verify each one actually has a usable auth field before
relying on it (see "Open questions still unresolved").
**Setup required before this works (not done yet — Cloudflare account
secrets were just added, GitHub PAT/allowlist still need setting):**
1. Create a GitHub personal access token (github.com → Settings → Developer
settings → Personal access tokens). No scopes are required — this only
reads the token owner's public identity via `/user`.
2. `wrangler secret put ALLOWED_GITHUB_LOGINS` — your GitHub username(s),
comma-separated. Optionally `ZDR_GITHUB_LOGINS` (see "Data handling").
3. Configure each client with `Authorization: Bearer <your PAT>` — see
`docs/client-setup.md`.
**Optional service-token path.** A static bearer token
(`SKILLS_READ_TOKENS`) is checked first, before the GitHub lookup, so
automation (the deploy workflow's smoke test, a script) doesn't need to
hold your actual GitHub credential. It's equivalent in privilege to a
GitHub-authenticated call, not a separate tier.
Cloudflare deployment credentials (`CLOUDFLARE_API_TOKEN`,
`CLOUDFLARE_ACCOUNT_ID`) live only in CI secrets — never in client
configuration or tool responses. Your GitHub PAT lives only in your own
client configs and is never sent anywhere but GitHub's API and this Worker.
## Data handling: `required_zdr`
Some skills may describe workflows unsuitable for disclosure to every
connected AI provider (per your answer to the open question below). Those
skills set `required_zdr: true` in frontmatter. A caller — a GitHub
identity or a static token — is cleared to see them only if it's in
`ZDR_GITHUB_LOGINS` or `SKILLS_ZDR_TOKENS` respectively; both default to
empty, so clearance is opt-in and fails closed. This is configured **per
client**, exactly as asked: a client whose connected provider has no ZDR
agreement simply never gets a login/token added to those lists, and the
skill doesn't exist as far as it can tell (see "MCP interface" above for
why that's `SKILL_NOT_FOUND`, not a permission error).
`required_zdr` is an access-control flag, not a content-type flag — it
applies the same way to a Markdown instruction file or a `scripts/` file.
## Publishing
1. Edit a skill, bump its version.
2. Push/merge to `main`.
3. `validate.yml` runs on PRs and pushes; `deploy.yml` runs only on push to
`main`: validate → build snapshot from the exact commit → typecheck →
test → `wrangler deploy` → smoke test.
4. A failed step leaves the previous deployment live. Deploys are
serialized (`concurrency: production-deploy`) so an older build can
never overwrite a newer one.
5. Rollback: re-run `deploy.yml` from a previous green commit (or
`wrangler rollback` if using Cloudflare's built-in version history).
"Latest" means latest **successfully deployed** commit, not necessarily
branch HEAD.
## Security notes
- Skill files are restricted to `.md`/`.txt` (or, under `scripts/`,
`.sh`/`.py`/`.js`/`.ts`/`.rb`) under configured size limits; retrieval
rejects path traversal, absolute paths, and anything outside a skill's
declared `files` allowlist (`src/tools.ts`, tested in
`tests/tools.test.ts`).
- No skill script is ever executed by validation, the build, or retrieval —
`scripts/` files are served as inert text with an `executable` flag; a
client chooses whether to run one, under its own local permission model.
- `requires` in frontmatter documents what a skill's instructions assume a
client has access to — it grants nothing by itself.
- Retrieved content still enters the connected AI provider's context.
Don't put secrets or incident-specific sensitive detail into a skill.
- Registry instructions are not higher priority than system instructions,
user authorization, or local access controls — the bootstrap text in
`docs/client-setup.md` says this explicitly so it travels with every
client's standing instructions.
## Resolved decisions
- **Auth mechanism:** direct GitHub PAT validation against `api.github.com`,
no OAuth App, no redirect flow — see "Authentication" above. (An earlier
pass built a full OAuth-provider design; it was replaced once a simpler
option was pointed out — see git history if you want the OAuth version.)
- **Client surfaces:** all of them — Claude Web, Claude Code, Codex CLI and
hosted surfaces, OpenCode, and ChatGPT Web. Claude Code, Codex, and
OpenCode all take a static bearer/API-key header in their MCP config, so
a pasted GitHub PAT works directly. Claude Web and ChatGPT Web connectors
are generally built around an interactive OAuth "Authorize" redirect
rather than a pasted-token field — confirm each actually has a usable
auth field for this before relying on it (see "Open questions still
unresolved"). If one doesn't, the OAuth-provider design is the fallback.
- **Publish freshness:** "publish after CI finishes" is fine as-is; no
change made.
- **Local scripts/binaries:** text scripts (`.sh`/`.py`/`.js`/`.ts`/`.rb`)
are now supported under `scripts/`, served as plain text with an
`executable` flag, never run by the registry — see "Authoring a skill"
step 6. **Compiled/binary assets remain out of scope.** A binary can't be
reviewed by a human or model before a client runs it, which defeats the
trust model this whole registry depends on (retrieval never grants
execution); it also adds integrity/signing and storage concerns this
registry doesn't currently handle. If a real skill turns out to need a
binary, the right shape is a separate, explicitly-confirmed release
channel with signed artifacts — not `skills_get_file` — and should be
designed against a concrete skill that needs it, not speculatively.
- **Long-running tasks spanning a deploy:** staying with active-snapshot-
only for now. The existing `REVISION_UNAVAILABLE` behavior already keeps
a client from silently mixing revisions mid-task — it just has to restart
discovery. No evidence yet that this is a real problem for any actual
task; revisit with historical-revision serving only if one shows up.
- **ZDR-sensitive skills:** yes, some may need this — see `required_zdr`
under "Data handling" above.
## Open questions still unresolved
- Does the Claude Web and ChatGPT Web connector UI actually expose a field
for a static bearer token / API key, or only an OAuth "Authorize" button?
Not yet verified — no live deployment exists yet to test against.
## Explicitly out of scope for now
Embeddings/full-text search, a database, an admin UI, remote execution,
automatic native-skill installation, write tools, one-tool-per-skill
registration, runtime GitHub sync, historical-revision retrieval, and
compiled/binary skill assets.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues