skill-mcp

skill-mcp

Enables LLM clients to discover, load, and execute markdown skills with bundled scripts over HTTP, featuring progressive disclosure and sandboxed script execution with timeout and stall detection.

Category
访问服务器

README

skill-mcp

A FastMCP server that serves a library of skills — markdown instructions with bundled scripts — to an LLM client over HTTP, with progressive disclosure and sandboxed script execution.

Built against FastMCP 3.4.6 / Python 3.12.

uv sync
uv run pytest -q                 # 92 tests
uv run python acceptance.py      # 73 驗收項目,中文報告
uv run skill-mcp --port 8000 --context-tokens 30000

Point a client at http://127.0.0.1:8000/mcp.

中文文件在 docs/ — 安裝部署、skill 撰寫、script 規範、 hooks、驗收測試、疑難排解。acceptance.py 是給便宜/弱模型執行的驗收流程:中文 報告,失敗時直接給修復指示,一次只讓它修一項。


The idea: don't load skills, offer them

The naive skill server sends every skill's full text on every session. That is the single biggest cost in a skill system, and it grows linearly with the library — 200 skills at 2 KB each is ~100 K tokens before the user has said anything.

This server implements four levels of disclosure. Each one is only paid for when the previous one justified it:

Level Tool Cost When
1 list_skills ~30 tokens/skill every session
2 load_skill(name) one skill body after the model picks one
2b load_skill(name, section=…) one section when only part is needed
3 read_skill_file one reference file when the body points at one
4 run_skill_script script output only instead of reading code in

Level 4 is the one people skip. A 200-line profiler script costs ~2 K tokens to read and re-implement; running it costs the size of its JSON summary. The script is also the tested path, which the re-implementation is not.

Performance design

The hot path is list_skills: every client calls it, most calls end there. So it is the one path with a hard rule — no disk I/O, no locks, no per-request parsing.

  • Frontmatter-only indexing. Discovery reads the first 16 KB of each SKILL.md to get name/description/tags. Bodies are never read at startup.
  • Immutable snapshot. The index is a frozen dataclass swapped in atomically. Readers bind it to a local, so they can never observe a torn state and no synchronisation is needed at all — not even a read lock.
  • Refresh off the request path. A background task re-stats the tree every 5 s. The unchanged case is one stat per skill and allocates nothing, so edits show up within one interval without a request ever paying for a rescan.
  • mtime-validated body cache. Bodies are read once, then revalidated against (mtime_ns, size) — an edit invalidates precisely, without a full rebuild.
  • Blocking I/O stays off the event loop. Cache-miss reads go through asyncio.to_thread. Conversely, the pure-memory tools are declared run_in_thread=False, because for them a thread hop costs more than the work.
  • Stateless + JSON replies by default. stateless_http=True drops the per-session bookkeeping, and json_response=True skips SSE framing on every reply. Together they are what makes multiple workers possible.
  • libyaml (CSafeLoader) when available, with a pure-Python fallback.

Measured

MacBook, Python 3.12, 3 example skills. bench.py measures client-observed end-to-end latency — HTTP, JSON-RPC framing and pydantic validation included.

Single worker, one client at concurrency 16:

scenario tool rps p50 p95
catalog list_skills 1,136 11.6 ms 24.5 ms
search list_skills(query=…) 1,199 11.4 ms 15.9 ms
load load_skill 1,089 12.2 ms 26.2 ms
section load_skill(section=…) 1,123 12.4 ms 15.5 ms
script run_skill_script 160 97.8 ms 109.1 ms

Worker scaling, 3 concurrent bench clients (aggregate):

workers aggregate rps p50
1 ~1,247 34 ms
4 ~1,915 22 ms

Read that second table honestly: 4 workers buy ~1.5x, not 4x. At this point the benchmark client is itself a Python process saturating its own event loop, so it is measuring the pair, not the server. The server-side headroom is real — p50 dropped 35% at the same offered load — but if you need a defensible number for your own deployment, drive it with a non-Python load generator.

script throughput is deliberately bounded: interpreter startup dominates (~50 ms for python -I), and --max-script-concurrency (default 8) caps how many subprocesses can exist at once. That cap is what stops a burst of script calls from starving the cached paths. Raise it if your scripts are I/O-bound, lower it if they are CPU-bound.

Scaling out

uv run uvicorn skill_server.app:app --workers 8 --port 8000 --loop uvloop

Each worker keeps its own index (a few hundred KB) and its own refresh timer, so after an edit they converge within one interval. Because the server is stateless, no session affinity is needed and you can put any load balancer in front of it.

Scripts that call APIs

This is where skill servers hang, so it gets first-class treatment. Three mechanisms, plus a skill that documents the root cause.

1. Output survives the kill

A killed script used to return an empty string — you learned only that it failed, never where. Now the buffers are owned by the runner rather than by the reader coroutines, so whatever was printed before the kill comes back:

status:     stalled
stdout:     step 1: auth ok
            step 2: GET /v1/orders      <- died here
silent_for: 3.0 s
hint:       Killed after 3s with no output. Last output: 'step 2: GET /v1/orders'. ...

2. Stall detection — fail in seconds, not at the ceiling

A hung API call and a slow one look identical until you notice nothing has been written for a while. stall_timeout (default 20 s) kills a script that has gone completely silent, so a hang costs seconds instead of the full timeout.

That is what makes status more useful than exit_code:

status Meaning What to do
ok exited 0
failed exited non-zero read stderr
stalled silent for stall_timeout blocked, not slow. Don't just raise the timeout
timeout still printing at the ceiling genuinely slow; raise timeout

A script that prints progress is never mistaken for a hung one, which is the whole reason the api-fetch skill prints before each call rather than after.

3. Live progress

Output lines are streamed to the client via ctx.info while the script runs, so a slow call is visibly alive. stderr only — stdout is the result and is returned in full anyway, so streaming it too would duplicate the payload into the client's log. Hence the convention: progress to stderr, results to stdout.

The environment fix you may not have noticed

The original _child_env allowlist stripped HTTPS_PROXY, NO_PROXY, REQUESTS_CA_BUNDLE and friends. Behind a corporate proxy that alone makes every outbound call hang — an outbound connection with no proxy variable doesn't fail fast, it blocks until the connect timeout, which is often longer than the server's own. These are now forwarded by default (--no-network-env opts out). Unrelated secrets in the server's environment are still not inherited; there's a test asserting exactly that.

Really long work

For scripts that legitimately run for minutes, --enable-tasks lets a client run the tool in background mode and poll, instead of holding a request open past its own timeout. Opt-in because it needs fastmcp[tasks] (pydocket + Redis) — too heavy to impose when stall detection covers the common case.

The root cause, though

Most hangs are the script's fault, not the server's. skills/api-fetch/ is a working fetcher that demonstrates the fix, and skills/api-fetch/references/hangs.md is the diagnostic tree. The short version:

Client Default timeout
requests none — waits forever
urllib.urlopen OS default, ~2 min
httpx 5 s

Keep the retry budget under the server's timeout — timeout x (retries+1) + backoff_cap x retries — or the server kills the script mid-retry and the retries bought nothing. fetch.py prints its own budget on line one so a mismatch is visible before it costs you a run.

Secrets go through the tool's env argument, never args: argv shows up in process listings and in the MCP call log.

Execution policy: timeouts, not modes

The skill declares how long each script may take. That is the only thing that cannot be known from the output, because it has to be decided before the script runs:

execution:
  default: {timeout: 60}
  scripts/submit.py:
    timeout: 15
    description: fires the job, API returns a uuid and keeps working
  scripts/report.py:
    timeout: 300
    stall_timeout: 30

Whatever the script prints is the answer. A script that fired off work prints a uuid; one that waited prints data. The server does the same thing with both — returns stdout verbatim.

An earlier version had mode: background | sync. It was removed because it changed no behaviour: it only let the server guess at the meaning of something it already had, and the guess was wrong on ordinary data —

script output guessed "job key"
{"key": "7f3a-uuid", "status": "accepted"} 7f3a-uuid
{"id": 123, "order_no": "SO-001"} 123 ✗ that's an order id
{"id": "u-88", "name": "Amy"} u-88 ✗ that's a user id

A wrong handle is worse than none, because it looks right. Use description to tell the model what a script does — natural language is both more precise and more flexible than an enum.

A short timeout is an assertion

Setting timeout: 15 says "this should answer immediately". So overrunning it produces a different diagnosis than overrunning a 300 s ceiling:

  • short ceiling → the endpoint is finishing the work before replying; the async boundary is on the wrong side. A bigger timeout hides it, not fixes it.
  • long ceiling → genuinely slow; raise timeout or page the work.

A test pins that the two give opposite advice, since conflating them sends you in the wrong direction.

Hooks: pre and post checks

Drop executable checks into the bundle. They are ordinary scripts speaking JSON over stdin/stdout, so they run under the same sandbox and need no new language:

my-skill/
├── hooks/
│   ├── pre.py     # gate: deny, inject env, or rewrite args
│   └── post.py    # audit: reject or rewrite the result
└── scripts/
stage receives exit 0 exit non-zero
pre {skill, script, args, mode, caller} allow; optional {"env":…}, {"args":…}, {"note":…} deny — reason reaches the caller, script never runs
post … + {"result": …} pass through; optional {"result":…} to replace fail the call

Global hooks (--hooks-dir) run for every skill: global pre first (org policy rejects early and cheaply), skill post first then global (so org audit sees what actually went out).

Worked example in skills/internal-api/hooks/ — the pre-hook rejects oversized payloads and injects a request id; the post-hook writes an audit line and fails any background submit that produced no key.

Cost, measured, not estimated:

p50 subprocesses
script, no hooks 12.9 ms 1
script + pre + post 41.8 ms 3

That is ~3.2x on run_skill_script, and it is the real price of an enforced check. It is paid only by skills that declare hooks — a skill with no hooks/ directory takes the original path, and list_skills / load_skill never touch it at all (still 1,149 rps).

Loading skills at runtime

Skills load into a running server. There is no cold start and no restart, ever — for a new skill, an edited body, a changed execution policy, or a hook added after the fact.

Two mechanisms:

  • Automatic: the background refresher picks up changes within --refresh-interval (default 5 s; measured at 1.0 s with a 1 s interval). Costs one stat per skill when nothing changed.
  • Immediate: reload_skills forces a rescan and reports what moved.
reload → generation 1 -> 2  added=['hot-added']
list_skills sees it immediately: True

A newly added skill is runnable on the same call, not merely listed. A test pins the harder case too: changing a skill's mode from sync to background and adding a hooks/pre.py both take effect on reload, without a restart.

Multi-worker deployments converge within one refresh interval, since each worker holds its own index.

Fire-and-forget jobs ({"key": "<uuid>"} APIs)

For an endpoint that accepts work, returns a key immediately, runs to completion on its own, and writes the result to a database. This is a deliberate architecture — it keeps long work out of the model loop, so no tokens are spent while it runs — not a race to be closed.

submit  ->  45 ms, returns the key, never waits
fetch   ->  collect the result by key, possibly in a different session

The risk is losing the key, not losing the job

The job completes either way. What breaks is retrieval: a key that only ever existed in the model's context vanishes when that context rolls — minutes, on a 30 K window — and the result then sits in the database unaddressable.

So submit also appends the key to an append-only ledger under SKILL_STATE_DIR, which survives calls, sessions and restarts:

["list", "--limit", "10"]     # what did I submit?
["list", "--grep", "Q3"]      # find one by label

Always pass --label. Weeks later a uuid tells you nothing and a label tells you everything. Append-only (one JSON object per line) so concurrent submits cannot clobber each other — there's a test firing 12 at once.

--state-dir sets the root (default ~/.skill-mcp/state); each skill gets its own isolated subdirectory.

Deliberately absent

  • No waiting after submit. That would put the long task back inside the model loop, which is the thing this design avoids.
  • No cancellation. Only your backend knows whether killing a half-finished job is safe.
  • await exists but is a last resort — bounded and heartbeating, for the rare case you need the result in the same turn.

If your poller must wait

Any script that polls has to print a heartbeat, or stall detection kills it at 20 s regardless of timeout. Two tests pin the contract: a silent poller must be killed (it is indistinguishable from a hang), a heartbeating one must survive.

Small context windows

On a 128 K hosted model, tool output size is a rounding error. On a 30 K local model it is the whole problem — and it usually presents as a timeout, not as an error, because the wall-clock cost is the model prefilling tokens it cannot fit anyway.

A perfectly ordinary internal REST response:

tokens
500-row order list, as returned 59,222
The model's entire context window 30,000

The script finished in 0.2 s. Nothing hung. The model then had to swallow twice its context, which is where the time went.

Byte-truncation is the wrong fix

Cutting JSON at a byte offset produces text that no longer parses. The model burns tokens on a fragment and still cannot answer. shaping.py reduces structurally instead — shrink the largest collection, keep the envelope, say what was dropped, stay valid JSON:

{"total": 500, "data": [ ...500 rows... ]}                    59,222 tok
{"total": 500, "data": [ ...3 rows... ], "_truncated": {...}}     442 tok

total survives, so "how many are there" is still answerable. _truncated carries a hint telling the model to narrow its arguments rather than retry.

Non-JSON keeps head and tail, never head alone: errors cluster at the end of a log, so head-only truncation reliably discards the part that mattered.

Set the budget to your actual model

uv run skill-mcp --context-tokens 30000 --port 8000

Everything sizes off this: a single tool result may occupy --context-share (default 25%) of the window, because the output has to coexist with the prompt, the skill body, the conversation and the reply. The 128 K default will hand a 30 K model output it cannot digest.

Better: filter at the source

The server can only guess which rows mattered; your query cannot. rest-client filters before the data is ever produced, and the server passes SKILL_OUTPUT_BUDGET_BYTES to every script so it can warn when it is about to overshoot:

call tokens
[url] — everything, server reduces it 442
--count-only 43
--limit 5 --fields id,status,amount 134

On a small window the useful order is: --count-only to see the shape, then --fields with a small --limit, then fetch only what you need.

Security model

The client is treated as semi-trusted — it is an LLM, and LLMs can be talked into things by the documents they read. The skill bundles on disk are trusted. So every control exists to guarantee one property: a caller can only run a script that a skill author put in that skill's own scripts/ directory.

Control Implementation
Path jail resolve() then is_relative_to(skill_dir) — blocks ../, absolute paths, and symlinks pointing out of the bundle
Execution scope must live under the skill's own scripts/
Interpreter allowlist dispatch on suffix (.py, .sh, .js); the executable bit grants nothing
No shell create_subprocess_exec with an argv list — quoting/injection is not a category that exists here
Clean environment minimal allowlisted env, so server API keys are not inherited
Timeout start_new_session=True + killpg, so a forking script's children die too
Output cap capped retention but continued draining, so a chatty script can't deadlock on a full pipe
Concurrency cap semaphore, so scripts can't starve the cached paths

Tests cover each of these, including a symlink-escape case and a check that MY_API_KEY in the server's environment does not reach the child.

What this is not: the script still runs as the server's user, with its filesystem and network access. This is a jail against path and argument attacks, not a container. If skill authors are untrusted, run the whole server in one — that is the correct boundary, and it is not this code's job.

Layout

skill_server/
  index.py     discovery, frontmatter parsing, snapshot + body cache
  runner.py    sandboxed subprocess execution
  server.py    FastMCP tools, resources, timing middleware, CLI
  hooks.py     pre/post hook chain (skill-local + global)
  shaping.py   fit output to a context budget without breaking JSON
  app.py       ASGI entrypoint for multi-worker deployment
skills/
  csv-profile/   SKILL.md + scripts/profile.py + references/
  text-stats/    SKILL.md + scripts/wordcount.py
  repo-digest/   SKILL.md + scripts/digest.sh   (a non-Python script)
  api-fetch/     SKILL.md + scripts/fetch.py + references/hangs.md
  rest-client/   SKILL.md + scripts/call.py   (filters at the source)
  async-job/     SKILL.md + scripts/job.py    (submit / fetch / key ledger)
  internal-api/  two modes + hooks/pre.py + hooks/post.py
bench.py       load generator
tests/         69 tests: indexing, sandbox, hangs, modes, hooks, reload, budget, CLI

Skill format

Claude Code's format, so existing skills work unchanged:

my-skill/
├── SKILL.md            required
├── scripts/            optional, the only runnable location
└── references/         optional, read on demand
---
name: my-skill
description: One line. This is what the model sees in list_skills — write it as
  a routing decision, not a title.
version: 1.0.0
tags: [data, csv]
---

# My skill
...

Only name and description are read at index time. Nesting one level deeper (skills/<team>/<skill>/SKILL.md) also works, for per-team namespacing.

Adding a skill is mkdir + write SKILL.md — no restart, no registration; the refresher picks it up within 5 s.

Tools

Tool Purpose
list_skills(query?, tags?, limit) compact cards; the entry point
load_skill(name, section?) full body, or one heading's section
read_skill_file(name, path, max_bytes) a bundled reference file
run_skill_script(name, script, args?, stdin?, timeout?, stall_timeout?, env?) execute a bundled script; returns output even when killed
skill_server_stats() index state, subprocess counters, per-tool p50/p95
reload_skills() rescan the skill tree now, no restart

Resources mirror the read paths for clients that prefer attaching over calling: skill://{name} and skill://{name}/files/{path}.

Options

--skills DIR              skill root; repeat for several (default ./skills)
--host / --port / --path  bind address and mount path (default /mcp)
--refresh-interval SEC    index refresh timer, 0 disables (default 5)
--max-script-concurrency  concurrent subprocesses (default 8)
--script-timeout SEC      hard ceiling per script (default 30)
--script-stall-timeout    kill a script silent for this long (default 20, 0 disables)
--state-dir DIR           writable per-skill state, exposed as SKILL_STATE_DIR
--hooks-dir DIR           global pre.py / post.py applied to every skill
--context-tokens N        client's context window; sizes all output (default 128000)
--context-share F         max fraction of it one tool result may use (default 0.25)
--no-network-env          stop forwarding proxy/TLS vars to scripts
--enable-tasks            allow background execution; needs `uv sync --extra tasks`
--stateful                keep session state; needed for sampling/elicitation
--sse                     stream over SSE instead of plain JSON replies

--stateful and --sse both cost throughput and --stateful rules out multiple workers. Reach for them only when a feature actually requires them.

推荐服务器

Baidu Map

Baidu Map

百度地图核心API现已全面兼容MCP协议,是国内首家兼容MCP协议的地图服务商。

官方
精选
JavaScript
Playwright MCP Server

Playwright MCP Server

一个模型上下文协议服务器,它使大型语言模型能够通过结构化的可访问性快照与网页进行交互,而无需视觉模型或屏幕截图。

官方
精选
TypeScript
Magic Component Platform (MCP)

Magic Component Platform (MCP)

一个由人工智能驱动的工具,可以从自然语言描述生成现代化的用户界面组件,并与流行的集成开发环境(IDE)集成,从而简化用户界面开发流程。

官方
精选
本地
TypeScript
Audiense Insights MCP Server

Audiense Insights MCP Server

通过模型上下文协议启用与 Audiense Insights 账户的交互,从而促进营销洞察和受众数据的提取和分析,包括人口统计信息、行为和影响者互动。

官方
精选
本地
TypeScript
VeyraX

VeyraX

一个单一的 MCP 工具,连接你所有喜爱的工具:Gmail、日历以及其他 40 多个工具。

官方
精选
本地
graphlit-mcp-server

graphlit-mcp-server

模型上下文协议 (MCP) 服务器实现了 MCP 客户端与 Graphlit 服务之间的集成。 除了网络爬取之外,还可以将任何内容(从 Slack 到 Gmail 再到播客订阅源)导入到 Graphlit 项目中,然后从 MCP 客户端检索相关内容。

官方
精选
TypeScript
Kagi MCP Server

Kagi MCP Server

一个 MCP 服务器,集成了 Kagi 搜索功能和 Claude AI,使 Claude 能够在回答需要最新信息的问题时执行实时网络搜索。

官方
精选
Python
e2b-mcp-server

e2b-mcp-server

使用 MCP 通过 e2b 运行代码。

官方
精选
Neon MCP Server

Neon MCP Server

用于与 Neon 管理 API 和数据库交互的 MCP 服务器

官方
精选
Exa MCP Server

Exa MCP Server

模型上下文协议(MCP)服务器允许像 Claude 这样的 AI 助手使用 Exa AI 搜索 API 进行网络搜索。这种设置允许 AI 模型以安全和受控的方式获取实时的网络信息。

官方
精选