Skip to content

Skills & the hyprpilot MCP Server ​

Skills are SKILL.md bundles — reusable markdown instructions the agent can list and read, and whose roots the sidecar watches so edits announce themselves. They reach the agent only through hyprpilot's own in-tree MCP server, which the launcher auto-injects into the vendor's MCP config. The same server also serves prompts — including the launched profile's own system prompt — so an edited prompt can be re-read mid-session.

The catalogue is served over stdio to the vendor that spawned it. To share one catalogue with other clients instead, see Serving MCP over HTTP.

Skill bundles ​

The skills catalogue is configured under the mcp block. Every directory under a configured root that holds a SKILL.md is a skill, at any depth, following the Agent Skills specification:

txt
~/.config/hyprpilot/skills/
├── git-commit/
│   └── SKILL.md
├── linear-issue/
│   ├── SKILL.md
│   ├── references/
│   │   └── triage.md
│   └── scripts/
│       └── fetch.py
└── acme/                     # an organizational prefix, not a skill
    └── billing/
        └── refunds/
            └── SKILL.md      # the skill `acme/billing/refunds`

A skill is identified by its path under the root — git-commit, or acme/billing/refunds for a nested one. That path is its slug on every tool and the <skill-path> of its skill:// URIs. A skill may also nest inside another; the enclosing skill then ships the nested one's files as its own, as the Skills extension requires.

A SKILL.md must carry YAML frontmatter with a name equal to its directory and a non-empty description. A skill missing either, or whose name differs from its directory, is skipped with a warning: the frontmatter is served verbatim, and a SEP-2640 host refuses an entry like that. The body may be empty — a description-only skill is still served. The name follows the Agent Skills rule — 1-64 of [a-z0-9-], no leading, trailing or doubled hyphen. Organizational prefixes are lowercase too, because the first one becomes a URI authority.

Hidden entries and anything a .gitignore (or .ignore) excludes are never discovered or served, so a script's .venv stays out. Symlinks are not followed. A bundle past SEP-2640's limits — 512 files or 16 MiB — is not served, and a warning says so.

Per-root include globs keep only matching skill paths and per-root ignore globs skip them; a path matching both is skipped. Globs match the whole path and * crosses /, so acme/* matches every skill under acme. Ignoring a skill does not ignore skills nested inside it — each is matched on its own path. On a path collision across roots, the first root wins. The first root also wins when a skill would nest with one from another root — by path (team in one root, team/review in another) or by directory (roots a and a/b reaching the same tree): the later skill is skipped with a warning, because its files would otherwise sit behind URIs the earlier skill already serves. Nesting within one root is unaffected. Two skills sharing a name (acme/refunds and billing/refunds) are both served, with a warning, since a host shows the name and must tell them apart by path. Missing roots warn and are skipped.

The compiled defaults seed the XDG skills root ~/.config/hyprpilot/skills (via a root patches entry), and the built-in mcp defaults (enabled: true, autoAcceptTools: ['*']) fill in the rest — so skills work out of the box once you drop a SKILL.md in. A profile's own mcp block wholesale-replaces the global one — point a profile at a different skills root, or disable the server entirely.

Prompts ​

The server also serves prompts, from two sources:

  • The profile's own system_prompt files, on by default (mcp.skills.system_prompts). The launcher still bakes them into the vendor at launch — that cannot change mid-session — but served as a prompt, an edited system prompt can be re-invoked without a relaunch. Every entry passes, including inject: false ones.
  • [[mcp.skills.prompts]] directories. Every *.md directly inside one is a prompt. The entry carries the same include / ignore / watch keys a skill root does, matched against the prompt name.

A prompt is named by its frontmatter name, else its file stem (AGENTS.md is AGENTS), and must fit [A-Za-z0-9_.-]{1,128}. Its body is served with any frontmatter fence stripped; title and description in that frontmatter are listed with it. The profile's files come first, so a directory can never shadow the system prompt; after that, the first prompt to claim a name keeps it.

Prompts reach a client two ways, because clients disagree about prompts:

ClientMCP prompts (prompts/list, prompts/get)hyprpilot://prompts/<name> resources
Claude Code/mcp__hyprpilot-skills__<name> slash commands, refreshed live@-mentions and resource tools
opencodeslash commands, refreshed on reconnect@-mentions and a resource tool
Codexignoredresource tools
Hermesmodel-callable get_prompt toolsresource tools

Prompt files are watched: a prompt directory directly, and a single prompt file through its parent directory, so an editor's atomic save — which replaces the file — is still seen.

Auto-injection ​

When mcp.enabled is true, mcp.skills.enabled is true (the default), and there is at least one skill or one loadable prompt to serve, hyprpilot prepends a stdio MCP server named hyprpilot-skills to the catalogue it hands the vendor. That entry launches hyprpilot mcp skills as a child of the agent — the vendor owns its lifetime; you never run it by hand.

  • The reserved name replaces any same-named server you configured. Rename it with mcp.skills.name.
  • Auto-inject is independent of mcps — mcps: [] does not suppress it. Set mcp.skills.enabled: false (this server only), mcp.enabled: false (every in-tree server), or leave both the catalogue and the prompts empty to turn it off.
  • This server is also gated on content: nothing to serve means nothing is injected. A profile with a system_prompt therefore gets the server even with no skills, because that prompt is served.
  • autoAcceptTools / autoRejectTools default the approval policy for the injected server; the default ['*'] accept makes skill calls frictionless.

The injected entry runs the current binary with one --skill-dir per skill root, one --prompt-dir per prompt directory and one --prompt-file per system_prompt file — see the mcp skills reference below for the exact shape.

What the server exposes ​

hyprpilot mcp skills is a small rmcp stdio server.

SEP-2640 skills ​

The server implements the stable Skills extension (overview: modelcontextprotocol.io/extensions/skills/overview), which started as SEP-2640, and declares it as capabilities.extensions["io.modelcontextprotocol/skills"] = { "directoryRead": true }:

  • Every file of a bundle is a resource at skill://<skill-path>/<file-path>, served raw — SKILL.md frontmatter included — because a host verifies what it reads against a digest and re-parses the frontmatter against the listing.
  • skills/list returns every skill as { uri, frontmatter, resources }: the SKILL.md URI, the frontmatter verbatim as JSON, and every file with its sha256: digest and byte size. The listing is a single page and carries ttlMs and cacheScope.
  • Digests live in resources only, SKILL.md's included. An earlier draft also put a top-level digest on each entry; nothing reads it now (Claude Code's client is behind a disabled flag), so it is gone.
  • The frontmatter in an entry is parsed from the same SKILL.md bytes the server serves and hashed, so a host's re-parse of the file it reads always matches. A skill whose frontmatter has no JSON form is not served rather than listed as {}.
  • skills/get { uri } returns one entry; a URI that is not a skill's SKILL.md is -32602.
  • resources/directory/read { uri } lists a directory's direct children: files with their metadata (a skill's SKILL.md named and described by its frontmatter), subdirectories as inode/directory. Every directory counts — a skill's root, any subdirectory, and an organizational prefix such as skill://acme. A file or an unknown path is -32602.

File bytes are read once per rescan and served from memory, so what a read returns always matches the digest the listing promised. An unchanged file (same size and modification time) is carried over rather than read and hashed again.

Which clients use it

As of October 2026 no shipping client turns this on by default. Claude Code 2.1.286 carries a client behind a disabled feature flag, and it caps a server at 100 skills; opencode, Codex and Hermes do not implement it. The tools and resources below are what reaches the model everywhere today — the SEP surface is there for when clients enable it.

Resources ​

  • hyprpilot://skills — the catalogue index: every skill with its description, as one markdown document, led by a header explaining how to load them. Attach it (@-mention it, or whatever your client calls that) and it costs no tool call — the client injects it directly.
  • skill://<skill-path>/SKILL.md — one per skill, listed. Any other bundle file is readable by its skill:// URI but not listed.
  • hyprpilot://prompts/<name> — one per prompt, listed.
  • file://<path> — a shared reference some skill declares (see References). Readable, never listed.

Only skills and prompts are listed, and that is a context-budget decision

Measured against a real 127-skill catalogue: listing one entry per skill costs 128 resources and ~105 KB. Adding one more entry per skill took it to 231 and ~170 KB, and enumerating all 479 individual references would reach ~607 entries and ~500 KB, over 120k tokens spent before a single skill is read. Bundle files and references are reachable by URI, by skills/list, and by directory read instead.

Tools ​

ToolPurpose
list_skillsEnumerate discovered skills with their metadata, reference count and file count.
read_skillFetch a skill's body (frontmatter stripped) by slug, plus manifests of its references and of the files it ships.
list_skill_referencesOne skill's reference metadata, without bodies.
read_skill_referencesFetch reference bodies by path.
read_skill_filesFetch files a skill ships — scripts, templates, its own references — by skill:// URI.
reloadForce a rescan. The roots are watched, so this is the fallback for a root reported degraded or off.

read_skill_files is what makes a bundle's own files reachable for a client that only calls tools, or one talking to the server over HTTP that cannot read the bundle's directory. A file that is not UTF-8 is described rather than inlined; read it with resources/read, which serves it as a blob.

Watching ​

Every configured root is watched recursively, and this is on by default. A change under one is coalesced over a 500 ms quiet window, rescanned, and announced — so an edit reaches connected clients without anyone calling a tool.

Two things make watching affordable rather than noisy. Changes under a hidden entry (a .venv, an editor's swap file) and plain file opens never wake the sidecar, and the diff decides what goes on the wire: a rescan that moved nothing announces nothing.

A root can lose coverage, and the sidecar keeps serving when it does:

SituationState
Normalwatching
Root does not exist, the inotify watch limit was reached, the watcher thread exited, or the backend reported an errordegraded
watch = false on that rootoff

list_skills reports this as a watch object ({ active, roots }), and its text summary names any uncovered root. active is true only when there is at least one root and every one of them is covered. When it is not, reload is the way to refresh.

An error the backend attributes to a path degrades only the roots that path falls under; one it cannot attribute degrades all of them, which is the only case where blaming a root that may be fine is honest.

Two cases a watch cannot cover, both of which are what reload is for:

  • A root on a filesystem that cannot deliver events — NFS, SSHFS, most FUSE mounts accept the watch and then never fire. There is no error to detect, so set watch = false on that root and use reload.
  • A reference file outside every configured root. The watch covers each root recursively; a skill citing a path above its root still serves that file fresh on every fetch, but a change to it is not announced.

What a rescan tells connected clients ​

Results carry a ttlMs of 24 hours — longer than a sidecar lives — so a client caches until told otherwise. Every rescan — the watcher's, or a reload — earns that by diffing and firing only what actually changed:

What you changedWhat fires
A skill's SKILL.mdresources/updated for its SKILL.md URI and for the catalogue index, plus resources/list_changed
A file a skill shipsresources/updated for that file's URI, the skill's SKILL.md URI (its resources set changed) and the index, plus list_changed
Added or removed a skillresources/list_changed, plus resources/updated for the index
A reference file a skill declaresresources/updated for the reference's file:// URI, and — since a copy is a file of each citing skill — for each copy's URI, each citing skill's SKILL.md URI and the index, plus resources/list_changed
A promptprompts/list_changed, resources/updated for its hyprpilot://prompts/<name> URI, plus resources/list_changed
Nothingnothing — a rescan that moved nothing never invalidates a client's cache

A file a skill shares with a skill nested inside it is one path, so it is announced once. MCP has no per-prompt update, so an edited prompt body is a prompt list change — the only way a client learns to re-fetch it.

The reload result reports the same thing ({ reloaded, prompts, membershipChanged, updated, filesChanged, referencesChanged, promptsChanged, watch }), so you can see what a rescan actually moved.

A client on 2026-07-28 opts in with subscriptions/listen (resourcesListChanged, promptsListChanged and/or resourceSubscriptions), and its notifications then ride that stream, tagged with the subscription id. A client with no stream — anything on an older revision — receives them as plain unsolicited notifications.

resources/list_changed fires on any change, not only on membership, precisely so a client that cannot subscribe still has a signal it can act on.

Reference bodies stay uncached — they resolve from disk on every fetch, so modified is always live. What the cache holds is each declared file's size and modification time, read once per rescan (one metadata() per unique file, however many skills cite it). The comparison uses the raw modification time, not the seconds-truncated string it serves.

A rescan refreshes the sidecar, not anything already in an agent's context — a skill body read earlier this session stays as it was until re-read. The notification is what tells a client to re-read; acting on it is the client's own behaviour.

References ​

A skill declares SHARED references in frontmatter, as paths relative to the skill's own directory:

markdown
---
name: git-commit
description: Stage and commit changes
references:
  - ../references/commit-style.md
  - ../references/output-diff.md
---

references: is hyprpilot's own key, not part of the Agent Skills specification: it names files outside the bundle, which many skills share. A file a skill ships inside its own directory needs no declaration — it is a bundle file, served at its skill:// URI. Like argumentHint, disableModelInvocation and scripts, the key passes through the frontmatter verbatim.

Shared references inside each skill ​

Each declared reference outside the bundle is also served as a file of every skill citing it, at the path its declaration names below the shared root: ../references/scm/commit-style.md becomes skill://git-commit/references/scm/commit-style.md. That is exactly where the SKILL.md link ../references/scm/commit-style.md resolves against skill://git-commit/SKILL.md (RFC 3986 — the first path segment is the URI authority, so .. cannot climb above the skill), so a host following the link stays inside the skill it approved.

  • Each copy is listed in that skill's skills/list / skills/get resources with its own digest and size, counts toward the 512-file / 16 MiB limits, reads through resources/read, and appears in resources/directory/read under skill://<skill>/references. Two skills citing one file list the same digest under their own URIs.
  • A skill that ships its own references/<x> on the same path as a declared shared file is not served, with a warning — one URI cannot name two files.
  • A declaration that stays inside the bundle, is absolute, points at a missing file, or reaches it through a symlink gets no copy; it stays reachable as the file:// reference below. Symlinks are refused for the same reason the bundle walk never follows them.
  • A nested skill's copies are also files of the skill enclosing it, listed in its resources under the same URIs, like the nested skill's own files.
  • For a nested skill (a multi-segment path such as acme/billing) the copy still sits at skill://<skill-path>/references/…, but its ../../references/… link resolves per RFC 3986 to skill://acme/references/…, outside the skill. The link reaches the copy only for a single-segment skill path.

The file:// reference resources and read_skill_references below are unchanged: tool clients keep addressing a reference by its canonical path.

The path is the address, and the identity ​

read_skill returns the skill body plus a manifest — every declared reference, with the canonical path that fetches it — but not their bodies:

jsonc
{
  "uri": "skill://git-commit/SKILL.md",
  "body": "…",
  "references": [
    {
      "path": "/home/you/.config/hyprpilot/skills/references/output-diff.md",
      "uri": "file:///home/you/.config/hyprpilot/skills/references/output-diff.md",
      "name": "output-diff",
      "size": 2481,
      "modified": "2026-08-04T09:12:33Z",
      "created": "2026-05-02T11:04:07Z"
    }
  ],
  "files": [{ "uri": "skill://git-commit/scripts/check.py", "size": 812, "mimeType": "text/x-python" }]
}

Pass those paths back to fetch bodies:

jsonc
read_skill_references { "references": ["/…/references/output-diff.md"] }
// body plus everything, in one call
read_skill { "slug": "git-commit", "bundle": true }

Addressing by path rather than by skill-and-name buys three things:

  • De-duplication. The same shared file is cited by many skills under different names. Two citations resolve to one path, so a path you already loaded needs no second fetch — and the server serves a repeated path once.
  • One call across skills. A path names a file, not a skill, so a single call fetches references belonging to as many skills as you like.
  • No collision rules. Paths are unique by construction, so two references sharing a label inside one skill are both fully addressable.

A reference is also a resource: its file:// URI (in the manifest row as uri) reads through resources/read. skill:// cannot name it, because that scheme addresses files inside one skill.

Only paths that some skill actually declares are served — a caller-supplied path is checked against that set, never joined onto anything, so the surface reaches exactly the files the skills already reference. Anything else is an error rather than a partial result.

The declared spelling (../references/output-diff.md) never reaches the tool output: it is meaningless outside its bundle directory. Paths are canonicalized, so .. collapses and two spellings of one file compare equal. The raw SKILL.md resource does still carry it, because that resource is the file verbatim.

list_skill_references { slug } returns the same manifest without the skill body, for checking what a skill cites before spending tokens on it.

Because the manifests always ride along — including as text footers on read_skill, for clients that never surface structured content — declining a body is never a silent gap.

Missing files and reference frontmatter ​

  • Missing file: a reference that is declared but cannot be read appears in the manifest and in any bundle as a status: not-found marker in its declared position, so the gap is visible where it belongs. It has no path, so it cannot be fetched.
  • Reference frontmatter: a reference may carry its own YAML frontmatter, parsed exactly as a skill's is. It is served with the fence stripped and its keys projected into the manifest entry's metadata — nothing is invented into it. A name: there overrides the display label.

A fetched reference carries its full metadata: the bundle header is built from the same manifest row the listing advertises, so the two cannot disagree.

txt
---
reference:
  path: /home/you/.config/hyprpilot/skills/references/output-diff.md
  uri: file:///home/you/.config/hyprpilot/skills/references/output-diff.md
  name: output-diff
  size: 2011
  modified: 2026-08-10T12:08:46Z
  created: 2026-08-10T10:32:30Z
---
# Output Diff
…

Timestamps ​

Skills and references both carry size, modified, and created as RFC 3339 UTC strings, so an agent can tell a convention it read last week from one that changed an hour ago. created is the filesystem birth time and is omitted where the platform or filesystem does not record one, rather than being back-filled from modified. Access time is deliberately absent: it records reads rather than writes.

Frontmatter passthrough ​

The loader keeps every frontmatter key losslessly. skills/list carries the map verbatim, as SEP-2640 requires. Everywhere else, metadata is carried in one block — never duplicated across surfaces:

  • Spec Resource fields are canonical: uri, name (the frontmatter name, which is the skill path's last segment), title, description, mimeType, size.
  • io.hyprpilot/skill (resource _meta) / metadata (tool output) — the same single block: the entire frontmatter map verbatim (keys pass through unchanged — no camelCasing; nested maps, arrays, numbers, and booleans all convert), minus the keys another field already carries, plus the runtime-derived path, bundleDir, size, modified, and created.

The block's key is deliberately io.hyprpilot/skill, not the extension's reserved io.modelcontextprotocol.skills/ prefix: it carries hyprpilot's own runtime data, not frontmatter the extension defines. It includes absolute filesystem paths (path, bundleDir), which tell any client the layout of the machine serving the skills — fine for the local stdio sidecar the launcher injects, worth knowing before serving over HTTP.

Three frontmatter keys are dropped from the block as duplicates. title and description equal the canonical Resource.title / Resource.description. references is superseded by the resolved reference manifest, which addresses each one by its canonical path.

Example — every key reaches the agent

This SKILL.md:

markdown
---
name: plan-hard
title: Plan hard
description: Deep planning
disable-model-invocation: true
metadata:
  owner: captain
  tags: [alpha, beta]
---

# Plan hard

…skill body…

…reaches the agent with title / description on the spec Resource fields, and every other key (name, disable-model-invocation, the nested metadata map) plus the runtime path / bundleDir intact under the single io.hyprpilot/skill block.

hyprpilot mcp skills ​

The subcommand that runs the server over stdio. You don't run this by hand — the agent vendor spawns it as a child via the auto-injected entry.

sh
hyprpilot mcp skills --skill-dir '{"dir":"/abs/path","ignore":[],"watch":true}' --prompt-file ~/AGENTS.md
FlagPurpose
--skill-dir <json>JSON-encoded skill root entry. Repeatable — roots are searched in declaration order.
--prompt-dir <json>JSON-encoded prompt directory, the same shape. Repeatable.
--prompt-file <path>One prompt file. Repeatable; earlier files claim a name first.

Each --skill-dir / --prompt-dir value is one self-contained JSON object:

json
{ "dir": "/abs/path", "include": ["glob1"], "ignore": ["glob2"], "watch": true }

The launcher passes one per resolved root, each carrying that root's own include and ignore glob lists and watch flag, so the sidecar rebuilds exactly what the launcher resolved — first path wins on collision, per-root filters applied independently. An absent or empty include means no allow-list, never "allow nothing". watch defaults to true, so a hand-written catalogue entry that omits it still gets a watched root.

The global flags apply here too; the server owns stdin/stdout for the MCP protocol, so logs go to stderr as everywhere else.