{
  "markdown": "# skill-mcp\n\nAn MCP server that serves a directory of **Agent Skills**. Point it at skills;\nit lists them, hands out their instructions and their bundled files, and runs\n**only the scripts a skill declares**.\n\nIt is a generic adapter, not a curated set: the skills are content it reads, and\nthe same build serves whatever it is pointed at.\n\n```bash\nnpx @chrischall/skill-mcp                       # serves the example skill bundled here\nSKILLS_DIR=~/my-skills npx @chrischall/skill-mcp\n```\n\nThe npm package is **`@chrischall/skill-mcp`** (unscoped `skill-mcp` is taken by\nsomeone else on npm). Everything else — the repo, the binary, the registry\nidentity `io.github.chrischall/skill-mcp` — is unscoped.\n\n## What a skill is\n\nA directory holding `SKILL.md`: YAML frontmatter (`name`, `description`, and\noptionally the `mcp-host:` block below) followed by instructions, plus whatever\nfiles those instructions refer to. Three layouts are found under each root:\n\n```\n<root>/SKILL.md              # the root IS one skill\n<root>/<name>/SKILL.md       # a directory of skills\n<root>/skills/<name>/SKILL.md\n```\n\n**A skill is served under its DIRECTORY name**, never under the `name` in its\nown frontmatter (a frontmatter `name` that disagrees is reported and otherwise\nignored; where the root itself is the skill, the root directory names it). That\nis a security rule, not a tidiness one: the owner's grant names a skill, so a\nbundle that could choose its own name could claim its neighbour's and be handed\nthe neighbour's granted script and granted variables. For the same reason, two\ndirectories that really do contribute one name — only possible across two roots\n— have **both** refused and reported, rather than one of them winning by scan\norder.\n\n## The tools\n\n| tool | arguments | returns |\n| --- | --- | --- |\n| `skill_list` | — | every skill found: name, description, when to use it, file count, whether it declares runnable scripts and **exactly which**; plus `problems`, so an empty list is never a mystery |\n| `skill_load` | `name` | the SKILL.md body **verbatim**, plus a manifest of the bundle's files. Referenced files are not inlined — that is what `skill_file` is for |\n| `skill_file` | `name`, `path` | one file from that skill's directory: text, or base64 with its media type. At most 1 MiB, `truncated: true` rather than a silent cut |\n| `skill_run` | `name`, `script`, `args[]`, `confirm` | `{exitCode, stdout, stderr, truncated, durationMs}` |\n\n`skill_file` takes **one** path. The design of record specifies a `paths[]`\nbatch (8 paths per call, 1 MiB per entry, 4 MiB per call, one bad path failing\nonly its own slot); shipping the singular form is a deliberate deferral, not an\noversight, and those three bounds are what a later batching change has to\nhonour. Read the caps as bounds on this server's own heap: they cap the\n**allocation**, not only the answer, because a hosted child has a hard 256 MiB\ndata limit and a bundle may be larger than that.\n\nEach skill is **also** registered as an MCP prompt (its body is the message) and\neach bundled file as a resource (`skill://<name>/<path>`), because a client that\nsupports those surfaces presents a skill better than a tool call does. It is a\nsecond door, never the only one: when this server runs on\n[mcp-host](https://github.com/chrischall/mcp-host) and a registration narrows\n`enabledTools`, `prompts/list` and `resources/list` come back empty and the\nhandshake stops advertising those capabilities — so **the tools carry the whole\nexperience**.\n\n## Discovery reports, it never goes quiet\n\nAnything that keeps a directory from being served comes back in `skill_list`'s\n`problems`, with the path and the reason: no `SKILL.md`, frontmatter that will\nnot parse, a name two directories both claim (both refused), a declared script that is not in\nthe bundle, a symlink leading out of the root or out of a skill, a filename the\nread tools could not address. One bad skill costs itself and never the listing,\nand there is no third outcome where something is dropped in silence — a\nsymlinked skill directory is **served** when it stays inside the root (so\n`skills/foo -> ../shared/foo` works) and **reported** when it does not.\n\n## The execution fence\n\n`skill_run` executes third-party code. Every rule below narrows **which** code\nruns and **what it is handed**; each has its own test.\n\n- **Only a script the skill DECLARES.** Not \"any file under `scripts/`\", not\n  \"anything executable\". An undeclared path is refused, saying it must be\n  declared and listing the ones that are.\n- **Only inside that skill's own directory.** The path is checked as a string\n  first (plain segments; no leading `/`, no `.` or `..`, no backslash, no\n  percent escape, no NUL) and then again after resolution: the **real** path,\n  with symlinks followed, must still be inside the skill's real directory, and\n  it must be a regular file. Both checks, because a string check alone misses a\n  symlink planted inside the bundle and a resolved check alone accepts shapes\n  that should never have been joined. The same discipline governs `skill_file`:\n  a read out of a skill directory is another skill's bundle at best.\n- **An argv array, never a shell string.** `spawn` with `shell: false`, no\n  interpolation, no `sh -c`. Arguments are passed through verbatim.\n- **An interpreter from a closed set**, named by the declaration — never\n  inferred from the extension and never taken from the file's own shebang, since\n  a file that can choose its own interpreter has already chosen its own program.\n  **v1 runs `node` and nothing else** (see *What v1 cannot run*).\n- **Bounded, and the call always returns.** A wall-clock timeout (60 s default,\n  per-script override, hard 300 s ceiling), 1 MiB captured per stream with\n  `truncated: true` rather than a silent cut, and one `skill_run` at a time. On\n  timeout the process **group** is signalled, which reaches the script and any\n  child that stayed in its group. It does **not** reach a grandchild that\n  detached into a group of its own, and such a grandchild also holds the stdio\n  pipes open — so the run settles on the process exiting plus a short drain,\n  under a hard deadline, rather than on the pipes closing. That is what\n  guarantees the tool call returns within its budget and frees the\n  one-at-a-time lock; it is not a guarantee that a deliberately detached\n  grandchild is dead. Bounding *that* is the tier's job (an unprivileged uid,\n  `prlimit` NPROC, and a machine that stops), not this adapter's.\n- **An env allowlist.** A script gets `PATH`, `HOME`, `LANG`, `TZ`, `TMPDIR`,\n  `MCP_DATA_DIR` when the host set one, and **exactly the variables that script\n  asked for and the owner granted** — never this server's own environment. The\n  fixed half mirrors mcp-host's `INSTALL_ALLOWLIST`\n  (`packages/runner-node/src/spawn-env.ts`), for the reason that file gives: a\n  host constant a hosted declaration cannot widen by one name.\n- **A non-zero exit is a normal, reported outcome** — exit code, stdout and\n  stderr all come back. It is never an exception that loses the output.\n- **`skill_run` is confirm-gated.** Without `confirm: true` it starts no process\n  and returns a dry-run preview of exactly what would run: the interpreter, the\n  argv, the working directory, the timeout, and the **names** of the variables\n  the script would be handed.\n\n### Why the confirm gate is blanket\n\nThe fleet convention gates mutating tools. Whether a given script mutates\nanything is something this server cannot know: it never reads a script, and it\ndeliberately does not analyse one — a machine-generated verdict about somebody\nelse's code gets trusted in a way an author's declaration does not. Unknown\neffects are therefore treated as mutating.\n\nThe obvious softening — let a skill mark a script read-only and skip the gate\nfor it — is refused because it is circular: the same author wrote the script and\nthe sentence describing it, so a self-declared \"read-only\" authorizes nothing.\nThat leaves a blanket gate. Its cost is one extra round-trip on a read-only\nhelper; its benefit is that the preview is the one place a caller sees the exact\ncall before any of it happens.\n\n### What the fence does NOT buy\n\n**A declared script is still arbitrary code.** These rules narrow which code\nruns and with what; none of them makes the code safe. A script you allow can\nread the whole skills tree, spend the machine's CPU, and send whatever it holds\nanywhere its network permits. `skill_run`'s output caps are truncation, not\nconfidentiality: nothing redacts a script's stdout, and nothing could.\n\n**This is not a sandbox.** Run it against skills you have read, or run it\nsomewhere that fences it — under mcp-host that means the isolated tier\n(`fly-machine`): a microVM per registration, an unprivileged uid, `prlimit`\nbounds, and nftables default-deny with a declared egress allowlist. This server\nis a narrowing on top of such a fence, not a replacement for one.\n\n## What v1 cannot run\n\nThe set of interpreters is `node`, one entry, and that is a measured decision\nrather than an oversight: mcp-host's runner image is Node + git + tar +\nutil-linux + nftables, with no `python3`, `curl` or `jq`, while real skills are\noverwhelmingly Python (70 `.py` against 1 `.js` in `anthropics/skills` at\n`3b3fad96`).\n\nSo a skill declaring a Python script is reported by `skill_list` under\n`unavailableScripts`, with the interpreter and this deployment's set named, and\n`skill_run` refuses it in the same words. **Its instructions still serve** —\nan instructions-only skill is a useful skill, and most published skills are\nexactly that. A pinned interpreter is a follow-up that arrives as a dependency,\nnever as an image change.\n\n## The `mcp-host:` declaration block\n\nOptional, inside SKILL.md's frontmatter:\n\n```yaml\n---\nname: weather\ndescription: Forecasts and geocoding.\nmcp-host:\n  version: 1\n  run:\n    - script: scripts/forecast.js\n      interpreter: node\n      env: [WEATHER_API_KEY]     # variables this SCRIPT asks for\n      timeout: 30                # seconds; clamped to 300\n  env:                           # fields proposed for the SERVER's environment\n    - name: WEATHER_API_KEY\n      secret: true\n  egress: [api.weather.example]  # hosts this skill reaches; a proposal\n---\n```\n\n**A declaration narrows; it never grants.** The author of the scripts also wrote\nthe block naming them, so nothing in it is an authorization — it says which\nfiles are entry points and what each wants. What makes a script runnable, and a\nvariable reach it, is somebody else accepting it.\n\nRead strictly: YAML 1.2 core schema, anchors and aliases refused, a 64 KiB cap,\nan unknown MAJOR version refused wholesale, unknown keys ignored **and reported\nby name**, and a block that does not parse reported with the parser's position\nrather than treated as absent. A broken block costs a skill its scripts, never\nits instructions, and never the rest of the listing.\n\n## Configuration\n\n| variable | meaning |\n| --- | --- |\n| `MCP_SKILLS_PATH` | `:`-separated slot roots, injected by mcp-host's runner. Wins over everything |\n| `SKILLS_DIR` | the same thing for local use. Read only when `MCP_SKILLS_PATH` is unset |\n| `MCP_SKILL_RUN` | optional JSON `[{skill, script, env?}]` — the owner's grant. **Narrow-only** |\n| *(neither set)* | this package's own `skills/` directory |\n\n`MCP_SKILL_RUN` deserves the emphasis. When it is present, what may run is the\ndeclaration **intersected** with it — a row naming a script the skill did not\ndeclare grants nothing (and is reported), and a row naming a variable the script\ndid not ask for grants nothing. There is no spelling of it that makes something\nrunnable which a skill did not declare, which is what makes it safe to read from\nan environment that also carries a registration's own variables.\n\n**When it is absent, the default depends on whether a host started this child,\nand the hosted half is fail-closed.**\n\n- **Hosted** — any variable mcp-host's runner *injects* is present\n  (`MCP_SKILLS_PATH`, `MCP_HOST_METER_FILE`, `MCP_DATA_DIR`,\n  `MCP_BLOB_BASE_URL`): **nothing is granted and nothing runs.** Every skill's\n  instructions and files are still served — that is a working, useful connector.\n  The reason is that one child holds one environment holding every credential the\n  owner set, so a skill whose frontmatter named its *neighbour's* variable would\n  otherwise be handed the neighbour's credential with nobody having decided to\n  give it. It deliberately does not key on `MCP_SKILLS_PATH` alone: mcp-host does\n  not inject that variable yet, so today's only hosted channel is `SKILLS_DIR` in\n  a registration's plain `env`, and that must not land on the open default. The\n  marker check can only ever move the default in the fail-closed direction.\n- **Standalone** — no injected marker at all: the skill's own declaration\n  stands. Nothing is injecting anything, and the person who pointed the server at\n  a directory is the owner.\n\n`skill_list` reports which case it is (`grantFrom`, plus a `grantNote` in the\nhosted one) and lists a skill's declared-but-ungranted scripts, so \"nothing\nruns\" is never indistinguishable from \"nothing was declared\".\n\n## Trust posture\n\n- **This server's code is the operator's**; the skills are yours. It reads a\n  fixed set of directories handed to it, fetches nothing, installs nothing, and\n  has no tool that takes a path outside a skill's own directory.\n- **It vets nothing.** There is no badge, no publisher allowlist, no scan. A\n  skill's instructions and its scripts are exactly as trustworthy as whoever\n  wrote them.\n- **A read is treated as dangerously as an execution**, because the directory it\n  reads from sits beside everything else on the machine.\n- **It caches nothing and stores nothing.** The catalog is scanned once at boot\n  and held in memory; no file is written anywhere.\n\n## Hosting on mcp-host\n\n`mint.yaml` at the repo root says how this MCP wants to be registered. Four\nthings it deliberately does **not** propose, because only a registration can\ndecide them:\n\n- **The runtime — and it *could not*, by rule.** A manifest may never name one\n  (docs/MINT-MANIFEST.md §5): which tier a registration lands on is decided by\n  who is asking, not by the package, since a file that could ask for\n  `fly-shared` would be a stranger's package requesting a seat on the operator's\n  own machine. A hosted skill server belongs on the **isolated** tier\n  (`fly-machine`) with a declared egress policy, and that is the registration's\n  choice to make.\n\n- **The skills themselves.** They arrive as a pinned dependency (a\n  `github-archive` naming a repository and an exact commit) and land in a\n  read-only slot the runner names through `MCP_SKILLS_PATH`. This package cannot\n  know which ones a given registration carries.\n- **`state.dataDir`.** The adapter needs no persistence. Turn it on when a\n  registration's skills have scripts that need somewhere to write — the slot is\n  read-only, so `MCP_DATA_DIR` (with it on) or `TMPDIR` (without) is where a\n  script's output goes — and give the reason there.\n- **The real egress allowlist.** `mint.yaml` proposes `allow: []`, which is what\n  the adapter itself needs: it reaches nothing. The hosts a registration needs\n  are the ones its SKILLS declare, shown at the preview with who declared them\n  and accepted by the owner. On the isolated tier a host that is not on the list\n  appears either as an HTTP 403 from the loopback proxy or as a plain timeout —\n  the two are indistinguishable from inside a script, so `skill_run` attaches a\n  note saying so whenever a call fails on a machine that looks fenced.\n\nNarrowing `enabledTools` to `[skill_list, skill_load, skill_file]` is the\nnon-executable switch, enforced at the gateway rather than here — a stronger\nstatement than this server refusing `skill_run`, and it costs the prompt and\nresource surfaces entirely.\n\n## Development\n\n```bash\nnpm install\nnpm run build      # tsc → dist/, esbuild → dist/bundle.js\nnpm test           # tsc typecheck + vitest\n```\n\n---\n\nThis project was developed and is maintained by AI. Use at your own discretion.\n",
  "bytes": 16110,
  "sha": "729404de7ed5a35e9bdd59b4810b7da860ea12d5b36cdef1da768dc88d3326fc",
  "repo_slug": "chrischall/skill-mcp",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/mcp_io_github_chrischall_skill_mcp_4035aed2/readme"
}