skills-mcp-server
by AutomateIP
README.md
# skills-mcp-server
An MCP server, built with [FastMCP](https://gofastmcp.com), that serves a directory of
`SKILL.md`-based "skills" over the network — as both MCP **resources** and MCP **tools**.
## How it works
- **Resources** come from FastMCP's built-in
`fastmcp.server.providers.skills.SkillsDirectoryProvider`. It scans a root directory, and
treats every subdirectory containing a `SKILL.md` file as one skill, exposing:
- `skill://{name}/SKILL.md` — the skill's main file
- `skill://{name}/_manifest` — a synthetic JSON file listing (path/size/hash) for the skill
- `skill://{name}/{relative/path}` — every other file in the skill folder (e.g. `references/*.md`)
This server configures the provider with `supporting_files="resources"`, so every supporting
file is individually listed by `list_resources()` up front (not hidden behind a lazy URI
template) — good for upfront discoverability by clients that just call `list_resources()`
once.
- **Tools** (`list_skills`, `get_skill`, `get_skill_reference`) are hand-written on top of the
same skills directory, since the provider only exposes resources, not tools. They parse full
YAML frontmatter (`interface:` blocks, nested lists, etc.) rather than relying on the
provider's internal simplified line-based frontmatter parser, so nested metadata (e.g.
`risk_level`, `confirmation_points`) comes through correctly.
- `list_skills()` → name, description, argument hint, risk level, and path for every skill.
- `get_skill(name)` → full parsed frontmatter, the markdown body, and a list of that skill's
supporting reference files.
- `get_skill_reference(skill_name, path)` → the text content and MIME type of one reference
file within a skill (e.g. `references/infoblox/procedure.md`), for clients that want a
specific reference file's content without walking `list_resources()`. `path` must be one of
the paths in that skill's `reference_files` list; anything else (including path traversal
attempts) is rejected.
- **Transport**: streamable-HTTP (`mcp.run(transport="http", ...)`), since this is meant to run
in a container and be reached by remote MCP clients rather than over stdio.
- **Skills directory is never baked into the image.** The Dockerfile only ships the server code;
the actual skills live on the host and are mounted as a **read-only** volume at container
runtime (`docker-compose.yml` mounts them to `/skills`). Swap in a different skills directory by
changing the compose volume mount or the `SKILLS_DIR` env var — no rebuild needed.
## Configuration
All configuration is via environment variables (see [`.env.example`](.env.example)):
| Variable | Default | Meaning |
|---|---|---|
| `SKILLS_DIRS` | _(unset)_ | Multiple named skill sources — see [Multiple skills directories](#multiple-skills-directories) below. Takes precedence over `SKILLS_DIR` if both are set. |
| `SKILLS_DIR` | `/skills` | **Legacy single-directory config.** Root directory to scan for skill folders. Still fully supported for backward compatibility when `SKILLS_DIRS` is unset. |
| `MCP_HOST` | `0.0.0.0` | Host/interface the HTTP transport binds to |
| `MCP_PORT` | `8010` | Port the HTTP transport binds to |
| `SKILLS_RELOAD` | `true` | If `true`, re-scan the skills directory on every request. Default on since an external process (e.g. a cron job re-cloning a skills repo into the mounted volume) may mutate content behind the server's back. Set `false` for a static skills directory to skip re-scan overhead. |
| `SKILLS_SUPPORTING_FILES` | `resources` | `resources` (list every file upfront) or `template` (lazy URI template) |
| `LOG_LEVEL` | `INFO` | Standard Python logging level |
A `GET /health` route is also registered for container/orchestrator liveness checks. It reports
every configured skill source by name.
### Multiple skills directories
This server can merge more than one skills collection behind a single MCP endpoint. Configure
`SKILLS_DIRS` as a comma-separated list of `name=path` pairs, one per source:
```bash
SKILLS_DIRS=agent-skills=/skills/agent-skills,automation-skills=/skills/automation-skills
```
Each source is identified by an explicit, stable `name` — this is what drives collision
resolution (below), not the order the sources are listed in.
**Collision policy: `agent-skills` always wins.** If the same skill directory name exists under
more than one configured source, the source literally named `agent-skills` always wins,
regardless of where it appears in `SKILLS_DIRS`. This is enforced by an explicit name check
(`source.name == "agent-skills"`) in `discovery._resolve_skill_dirs`, not by "whichever directory
happens to be scanned first" — reordering `SKILLS_DIRS` in your `.env` file later will not
silently flip precedence. If neither colliding source is named `agent-skills`, the
first-configured source wins (list order only matters as a tiebreaker in that case).
Every time a collision is resolved, the server logs a `WARNING` naming the skill, every source
that defines it, the winning source, and the shadowed source(s) — so shadowing is never silent.
This applies identically to both the tool layer (`list_skills`/`get_skill`/`get_skill_reference`)
and the resource layer (`skill://...` URIs): a shadowed source's own files are never served under
the winning skill's name, even if you ask for one of its supporting files by path directly.
`SKILLS_DIR` (singular) keeps working unchanged if you only need one source — it's treated as one
source named `"default"`. If both `SKILLS_DIRS` and `SKILLS_DIR` are set, `SKILLS_DIRS` wins and
`SKILLS_DIR` is ignored.
## Local development quickstart
Requires Python 3.11+ and [`uv`](https://docs.astral.sh/uv/).
```bash
uv venv .venv
source .venv/bin/activate
uv pip install -e .
# Point at any skills directory you like — this repo bundles a minimal
# example under ./example-skills:
export SKILLS_DIR=./example-skills
skills-mcp-server
# -> Starting MCP server 'Skills MCP Server' with transport 'http' on http://0.0.0.0:8010/mcp
```
(`pip install -e .` in a plain venv works too, if you'd rather not use `uv`.)
To run locally against a real, richer skills set instead of the bundled example (e.g. for manual
testing), just point `SKILLS_DIR` elsewhere — nothing else needs to change:
```bash
export SKILLS_DIR=/path/to/agent-skills/skills
skills-mcp-server
```
This only affects your local shell session — it does not touch `docker-compose.yml`, the
project's `.env.example` default, or the test suite (which always uses its own fixtures under
`tests/fixtures/`, independent of both `example-skills/` and whatever `SKILLS_DIR` you export).
### Quick smoke test with the FastMCP client
```python
import asyncio
from fastmcp import Client
async def main():
async with Client("http://127.0.0.1:8010/mcp") as client:
tools = await client.list_tools()
print([t.name for t in tools])
resources = await client.list_resources()
print([str(r.uri) for r in resources])
result = await client.call_tool("list_skills", {})
print(result.data)
asyncio.run(main())
```
Or with `curl` against the health route:
```bash
curl http://127.0.0.1:8010/health
# {"status": "ok", "skill_sources": {"default": "./example-skills"}}
```
## Docker quickstart
```bash
docker compose up --build
```
This builds the image (server code only — no skills baked in), mounts the bundled
`./example-skills` directory from the repo to `/skills` inside the container **read-only**, and
serves on `http://localhost:8010/mcp`. No configuration is required for this to work on a fresh
clone.
### Pointing at a different skills directory
Copy `docker-compose.override.yml.example` to `docker-compose.override.yml` (untracked — see
`.gitignore`) and edit the path:
```yaml
services:
skills-mcp-server:
volumes:
- /path/to/your/skills:/skills:ro
```
Compose automatically merges `docker-compose.override.yml` over `docker-compose.yml`, so your
own skills directory is used without editing the tracked file.
Or, if running the container directly instead of via compose:
```bash
docker run --rm -p 8010:8010 \
-v /path/to/your/skills:/skills:ro \
-e SKILLS_DIR=/skills \
skills-mcp-server:local
```
## Connecting an MCP client
Any MCP client that supports streamable-HTTP transport can connect directly to
`http://<host>:8010/mcp`. Example generic client config:
```json
{
"mcpServers": {
"skills": {
"url": "http://localhost:8010/mcp",
"transport": "http"
}
}
}
```
## Project layout
```
skills-mcp-server/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── docker-compose.override.yml.example # template for pointing at your own skills dir
├── .env.example
├── .gitignore
├── LICENSE
├── README.md
├── example-skills/ # bundled fixture so the server works out of the box
│ └── hello-world/
│ ├── SKILL.md
│ └── references/
│ └── greeting-styles.md
├── tests/ # pytest suite — uses its own fixtures/, never example-skills/
│ ├── conftest.py
│ ├── fixtures/skills/
│ ├── fixtures/multi/ # two-source fixtures for collision-policy tests
│ ├── test_config.py
│ ├── test_discovery.py
│ ├── test_server.py
│ └── test_tools.py
└── src/skills_mcp_server/
├── __init__.py
├── config.py # env-driven Settings; SKILLS_DIRS / SKILLS_DIR parsing, SkillSource
├── discovery.py # full YAML frontmatter parsing, multi-source merge + collision policy
├── tools.py # list_skills / get_skill / get_skill_reference MCP tool definitions
└── server.py # FastMCP app wiring, multi-provider root ordering, health route, entrypoint
```
## Running tests
```bash
uv pip install -e ".[dev]" --python .venv/bin/python
.venv/bin/python -m pytest
```
## License
Apache License 2.0 — see [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues