1776 字
9 分钟
给 Factorio Lua API 文档写一个 MCP server

起因挺无聊的:想写点 Factorio 的 mod 代码,又记不住 LuaSurface 到底是叫 create_entity 还是 make_entity,每次都得开浏览器翻 lua-api.factorio.com。标签页开了八九个之后我寻思——这事能不能让 Claude Code 直接帮我查?

写个 MCP server 就完了。

先搞清楚”文档”到底是什么#

Factorio 的 API 文档页面长得像 ReadTheDocs,顶部有个 “Libraries / New Functions / Modified Functions” 的大纲。用户给我的链接正好是 auxiliary/libraries.html 这一页。但我第一件事不是去抓 HTML——先看有没有结构化数据。

Terminal window
curl -sI "https://lua-api.factorio.com/latest/runtime-api.json" | head -6
HTTP/2 200
server: nginx
content-type: application/json
content-length: 1851970
last-modified: Wed, 25 Feb 2026 13:18:23 GMT
etag: "699ef69f-1c4242"

1.85 MB 的 JSON,配完整的 ETag 和 Last-Modified。继续:

Terminal window
curl -s "https://lua-api.factorio.com/latest/runtime-api.json" | python3 -c "
import json,sys
d=json.load(sys.stdin)
print(d['application_version'], 'api_version', d['api_version'])
for k in ('classes','events','concepts','defines'): print(k, len(d[k]))
"
2.0.76 api_version 6
classes 148
events 219
concepts 418
defines 60

还有一个 prototype-api.json(1.73 MB)装着 278 个 prototype 和 686 个 type。两个 JSON 加起来就是整份运行时和数据阶段的 API——Factorio 官方专门给 IDE 插件准备的机读格式,路径写在 auxiliary/json-docs-runtime.html 里。

用户原话说”利用现有 search API”。但文档站没有搜索端点——我抓了首页的 HTML,JS 里也没有 lunr / flexsearch 之类的客户端索引。唯一存在的”搜索 API”,其实就是这两份 JSON 本身。想清楚这一点,整个架构就定了:别做索引服务,做 grep

设计原则#

第一性原理地想三件事:

  1. 数据源是什么:两份 JSON + 若干张 auxiliary HTML(libraries、data-lifecycle、storage、mod-structure 这些没进 JSON)。
  2. 查询是什么形态:Claude 会用 regex 查 class/method/event 的名字,偶尔搜描述里的关键词。O(N) 扫一遍 10k 条记录完全够快,不值得上 tantivy / bleve。
  3. 怎么控流量:JSON 版本更新频率是以周计的(last-modified 是两个月前)。有 ETag,那刷新就走 If-None-Match 拿 304,一次握手大约 200 字节。

结论:

  • Python + mcp SDK(FastMCP)+ stdio transport
  • 本地缓存目录 ~/.cache/factorio-docs-mcp/,每个文件配一份 .meta.json 记 ETag / Last-Modified / fetched_at
  • 懒加载——第一次 tool call 才拉数据,之后常驻内存
  • TTL 默认 24 小时;超时就发条件 GET,拿到 304 就只更新 fetched_at

把 JSON 拍扁成 Record#

grep 引擎最怕嵌套结构。我把整棵树展平成一个 Record 列表:

src/factorio_docs_mcp/index.py
@dataclass(slots=True)
class Record:
kind: str # class|event|concept|define|method|attribute|...
name: str # 全限定名,例如 "LuaSurface.create_entity"
short_name: str
parent: str | None
stage: str # runtime|prototype|auxiliary
description: str
signature: str # 方法用调用签名,属性用类型签名
url: str # 深链到官方页面的 anchor
search_blob: str # name+signature+description 拼起来的小写串
raw: Any # 原 JSON,detail lookup 时返回

每条 class 会衍生出 class 本身 + 它的每个 method / attribute / operator 各一条。defines 是嵌套树,我递归展开到叶子,defines.alert_type.entity_destroyed 这种深层常量也能直接 get() 到。最后跑下来:

{
"total_records": 10099,
"by_kind": {
"attribute": 2303, "auxiliary": 12, "class": 148,
"concept": 418, "define": 1502, "event": 219,
"method": 960, "prototype": 278, "property": 3550,
"type": 686, "operator": 11,
"global_function": 3, "global_object": 9
}
}

10099 条记录 × 平均两百字节的 search_blob,regex 遍历一遍在我的 M 系列机器上 70 ms。够用了。

URL shape:细节都在 anchor 里#

文档站的 URL 规则我用 curl -I 一条一条验过:

kindURL 形状
classclasses/<Name>.html
method / attribute / operatorclasses/<Class>.html#<Class>.<member>
eventevents.html#<event_name>
conceptconcepts/<Name>.html
definedefines.html#defines.<dotted.path>
prototypeprototypes/<Name>.html
propertyprototypes/<Parent>.html#<name>
typetypes/<Name>.html
auxiliaryauxiliary/<slug>.html

这里有个容易踩的坑——event 没有独立页面:

Terminal window
curl -s -o /dev/null -w "%{http_code}\n" \
"https://lua-api.factorio.com/latest/events/on_tick.html"
# 404
curl -s -o /dev/null -w "%{http_code}\n" \
"https://lua-api.factorio.com/latest/concepts/Ingredient.html"
# 200

concept 有独立页,event 只能靠 events.html#<name> 的 anchor。我要是照着 concept 的规则给 event 也生成 events/<Name>.html,写出来的”深链”全是 404。事先 curl -I 过一轮就是为了这种 case——别相信”文档站 URL 规则一致”,规则永远有例外

ETag 让刷新几乎不要钱#

缓存层的主循环长这样:

src/factorio_docs_mcp/cache.py
resp = self._http().get(url, headers=headers)
if resp.status_code == 304 and entry.path.exists():
meta["fetched_at"] = time.time()
entry.meta_path.write_text(json.dumps(meta))
return entry
resp.raise_for_status()
entry.path.write_bytes(resp.content)

第一次跑,全是 200 OK,下载约 3.6 MB。第二次带着 If-None-Match 请求,所有 15 个文件都返回 304:

GET .../runtime-api.json "HTTP/1.1 304 Not Modified"
GET .../prototype-api.json "HTTP/1.1 304 Not Modified"
GET .../auxiliary/libraries.html "HTTP/1.1 304 Not Modified"
... (共 15 个文件)
rebuild after TTL=0 took 2.26s

304 的 body 是空的,一次往返加上 TLS 开销差不多 200–400 字节。15 个文件串行刷完 2.26 秒,绝大多数耗在 RTT 上——真要在乎延迟可以改并发,但 MCP 客户端长连着,冷启之后再没人在乎这两秒。

抽离 auxiliary HTML#

JSON 覆盖了 runtime 和 prototype,但 auxiliary/libraries.html 这类人工写的页面只有 HTML。我不想上 BeautifulSoup,stdlib 的 html.parser 够用:

src/factorio_docs_mcp/html_extract.py
class _TextExtractor(HTMLParser):
DROP = {"script", "style", "noscript", "svg"}
BLOCK = {"p","div","li","tr","br","h1","h2","h3","h4","h5","h6","pre",...}
def handle_starttag(self, tag, attrs):
if tag in self.DROP: self._skip += 1
elif tag.startswith("h") and tag[1:].isdigit():
self._out.append("\n\n" + "#" * int(tag[1]) + " ")
elif tag == "li": self._out.append("\n- ")
...

输出是带 # 标题和 - 列表符的类 markdown 文本,正好适合再被 regex 搜。入口函数先用正则切出 container-inner 主体块再喂给 parser,把导航、页脚都丢掉。

用户可以调 auxiliary("libraries"),返回 7727 字符的纯文本,serpenttable_sizepairs() 这些关键词都能搜到。

七个工具#

auxiliary 抓 auxiliary/*.html 的纯文本
cache_info 看每个缓存文件的 ETag、大小、年龄
get 按全限定名返回完整 JSON + 深链
list_entries 列出名字,支持 kind/stage/pattern 过滤
refresh 强制无条件刷新
search regex grep,支持 kinds/stages/field 过滤
stats 总数 + 上游版本 + 分 kind 计数

最核心的就是 search。举几个真实调用:

# 找所有创建 entity 的方法
search("create_entity", kinds=["method"])
# -> LuaSurface.create_entity
# 所有以 on_player_ 开头的事件
search(r"^on_player_", kinds=["event"], limit=20)
# -> on_player_alt_reverse_selected_area
# on_player_alt_selected_area
# on_player_ammo_inventory_changed ... (20 条)
# 搜 serpent 提到的地方
search("serpent", stages=["auxiliary"])
# -> libraries
# 精确匹配类名
search(r"^LuaSurface$", field="name", case_sensitive=True)
# -> class LuaSurface

get 拿全量:

get("LuaSurface.create_entity")
# {
# "kind": "method",
# "name": "LuaSurface.create_entity",
# "parent": "LuaSurface",
# "signature": "LuaSurface.create_entity(burner_fuel_inventory?: ..., name: EntityID, position: MapPosition, ...) -> LuaEntity",
# "url": "https://lua-api.factorio.com/latest/classes/LuaSurface.html#LuaSurface.create_entity",
# "raw": { ... 完整参数列表、返回值、描述 ... }
# }

#

写了个 full_stdio.py,起一个子进程跑 server,做完 MCP 初始化后挨个调工具验:

  • 7 个 tool 都注册了
  • 冷启建索引 0.07 秒(缓存命中),暖调 < 0.2 秒
  • create_entityLuaSurface.create_entity
  • ^on_player_ → 20 条 event,每条都以 on_player_ 开头 ✓
  • get("on_tick") URL 结尾是 events.html#on_tick(不是 events/on_tick.html)✓
  • get("defines.alert_type") 签名里列出全部 13 个枚举值 ✓
  • get("ContainerPrototype") 指向 prototypes/ContainerPrototype.html
  • get("BoundingBox") kind 是 type,指向 types/BoundingBox.html
  • 请求不存在的 name 返回 {"error": ...} 而不是抛异常 ✓
  • auxiliary("libraries") 返回 7727 字符的文本,包含 table_size
  • 拉不存在的 auxiliary 页面返回结构化 error,不崩溃 ✓
  • refresh() 强制刷新拿到 15 个 304 ✓

27 个 check 全绿。

挂到 Claude Code#

~/.claude.json 里加一段:

{
"mcpServers": {
"factorio-docs": {
"command": "/ABS/PATH/factorio-docs-mcp/.venv/bin/factorio-docs-mcp"
}
}
}

重启 Claude Code,对话里它就能直接 search("create_entity"),答案里带官方文档的深链,一键点进去验证。不用再翻八九个标签页。

收尾#

整件事最值得琢磨的是”existing search API”那个起点。Factorio 文档站字面意义上没有搜索 API,但它发布了一份完整的 JSON 机读索引——这份 JSON 就是搜索 API 的基础设施层。做 MCP 的时候,我从”抓 HTML → 建索引 → 提供查询”这种默认路径上脱出来,把问题降维成”拉 JSON → regex grep”,结果是 500 行 Python + 零第三方索引引擎 + 1 KB 刷新成本。

代码扔在 github.com/StevenLi-phoenix/factorio-docs-mcpuv pip install -e . 就能跑。有 Factorio modder 同好可以直接拿来用。

StevenLi-phoenix
/
factorio-docs-mcp
Waiting for api.github.com...
00K
0K
0K
Waiting...
给 Factorio Lua API 文档写一个 MCP server
https://blog.lishuyu.top/posts/给factorio文档写一个mcp-server/
作者
猫猫魔女
发布于
2026-04-24
许可协议
CC BY-NC-SA 4.0