项目叫 factorio-docs-mcp,顾名思义,原本是给 Factorio Lua API 做的 MCP server。打开看了一下——src 目录里除了一个空 __init__.py,什么都没有。
那就干脆改成 Skill 重新来过。
为什么不做 MCP
之前写过一篇 MCP vs Skills 的对比,这里不重复理论,说说这个具体需求为什么选 Skill:
MCP 的核心优势是进程隔离——每个 server 独立跑,OAuth、凭证、副作用都在沙箱里。但 Factorio 文档查询根本没有副作用,也不需要凭证,不需要实时数据。它就是”给 agent 塞一堆文档,让它能搜”。
这种需求用 MCP 等于杀鸡用牛刀:多一个常驻进程、多一套 JSON-RPC 协议栈、多一份部署配置——全是开销,没有收益。
Skill 反而刚好:一个目录、一个 SKILL.md、几个 Python 脚本。Agent 需要时加载,不需要时零开销。
不要动态 fetch
第一版的 search_docs.py 是运行时直接抓 Factorio 官网的 API JSON。被点名:
“no network at query time,需要确保 up to date 啊”
两个需求看起来矛盾——查询不联网,但还要跟得上 Factorio 版本更新。
解法是拆开:
- 构建阶段(一次性):拉
runtime-api.json,建本地索引 - 查询阶段(每次):打本地索引,零网络
更新时跑一个 update.py,它会拿远端 JSON 的 application_version 字段和本地缓存比较,版本一致就直接退出,不重建。Factorio 每次大版本更新才需要跑一次,平时查询完全离线。
cached = _cached_version() # 读本地 JSON 里的 application_versiondata = _fetch() # 拉远端 JSON
if cached == remote_ver: print(f"Already up to date (Factorio {remote_ver})") return
# 版本不一致才写盘 + 重建索引API_JSON.write_text(json.dumps(data, ...))_rebuild()为什么用 SQLite FTS5 而不是向量库
建本地 RAG 的第一反应通常是:embedding → 向量数据库 → 语义搜索。但这个场景有几个特点让我决定用 SQLite FTS5:
查的是 API,不是语义。开发者查文档通常知道大致的类名或方法名,搜 inventory insert 或 entity died,不是搜”怎么把东西放进箱子里”。这种查询模式更接近关键词检索,FTS5 的 BM25 排序已经够用。
零依赖。SQLite 是 Python 标准库。不需要装 chromadb、faiss、sentence-transformers,也不需要 GPU 或 embedding API。
porter stemming 内置。FTS5 支持 tokenize='porter ascii',insert 能匹配 inserting、inserted,词形变化免费处理。
实际效果测下来:搜 circuit network read signal 精准返回相关的 control behavior 属性,搜 player built 首条就是 on_built_entity。对于文档检索这种场景,BM25 不比余弦相似度差。
CREATE VIRTUAL TABLE docs USING fts5( doc_id UNINDEXED, kind, parent, name, signature, body, tokenize = 'porter ascii');4195 条文档(148 个类、960 个方法/属性、219 个事件),索引 1.7 MB,构建约 1 秒。
takes_table:坑最深的签名细节
Factorio API 有个 Lua 特有的调用约定——takes_table。960 个方法里有 108 个是这种形式:
-- takes_table = false,普通位置参数entity.die(cause, force)
-- takes_table = true,必须传具名参数表script.raise_biter_base_built{entity = some_entity}-- 等价于script.raise_biter_base_built({entity = some_entity})原来的实现把两种方式都当成位置参数展示。写 mod 的时候如果照着签名抄,takes_table = true 的方法会直接出错。
修法是在索引构建时区分:
def _method_sig(cname, mname, params, rvs, fmt): parts = [f"{p['name']}{'?' if p.get('optional') else ''}: {_type_str(p.get('type'))}" for p in params]
if fmt.get("takes_table"): inner = ", ".join(parts) table_opt = "?" if fmt.get("table_optional") else "" params_str = f"{{{inner}}}{table_opt}" # {key: type, opt?: type}? else: params_str = ", ".join(parts) ...现在 LuaEntity:destroy 的签名是:
LuaEntity:destroy({do_cliff_correction?: boolean, raise_destroy?: boolean}?){...}? 表示整个参数表可省略——因为 table_optional = true,直接 entity.destroy() 也合法。返回值加了 ? 标记 optional,多返回值也正确展开。
SKILL.md 的 ! 块
Skills 的一个冷门特性:在 SKILL.md 里用 ```! 开头的代码块,内容会在 skill 加载时自动执行,输出注入到 context。
```!python3 ${CLAUDE_SKILL_DIR}/scripts/build_index.py`${CLAUDE_SKILL_DIR}` 是 Claude Code 注入的变量,指向 skill 目录的绝对路径,不管从哪个工作目录加载都能找到脚本。
加载 skill 时,agent 看到的不是"有个命令要跑",而是直接看到执行结果:Index already exists at /Users/xxx/.claude/skills/factorio-docs/references/docs.db. Use —force to rebuild.
db 存在就跳过,缺失时从本地 JSON 重建——幂等,不会重复构建,也不联网。用户什么都不用做,skill 自己确认自己可用。
需要注意的是第一次踩坑:用了 `python` 而不是 `python3`,macOS 上 `python` 命令不在 PATH,导致 skill 加载报错。改成 `python3` 才正常。顺便整个项目的文档也统一成了 `python3`。
:::note`!` 块是预处理,不是 Claude 执行的——Claude 看到的已经是替换后的结果。`allowed-tools: Bash` 控制的是 skill **体内**给 Claude 预授权的工具,和 `!` 块是两回事。:::
## E2E 测试
31 个测试覆盖完整的 CLI 路径:
- **build_index**:跳过(db 存在)、重建(--force)、缺失 JSON 时退出- **update**:版本相同跳过、上报版本号- **search**:全文搜索、kind 过滤、精确查找、parent 成员列表- **边界**:空查询、特殊字符、--top 截断、缺失 db 时的错误提示
```bash~/miniconda3/bin/python -m pytest tests/test_e2e.py -v# 31 passed in 0.79s值得一提的是大小写不敏感的测试:FTS5 的 porter+ascii tokenizer 自动折叠大小写,--exact 用 COLLATE NOCASE,两条路都覆盖了,但测试不能拿 stdout 直接比——header 行包含原始 query,大小写不同 stdout 就不同。应该只比较结果条目:
def test_exact_case_insensitive(self): lower = run(SEARCH, "--exact", "luaentity") upper = run(SEARCH, "--exact", "LuaEntity") # 只对比结果行,header 行(含原始 query)允许不同 lower_entries = [l for l in lower.stdout.splitlines() if l.startswith("[")] upper_entries = [l for l in upper.stdout.splitlines() if l.startswith("[")] assert lower_entries == upper_entries最后项目在 StevenLi-phoenix/factorio-docs-skill,有写 Factorio mod 需求的可以直接 clone 到 ~/.claude/skills/factorio-docs/。