Initial commit: Claude Co-Engineer

A Factorio 2.x mod plus MCP server that lets Claude answer questions about
a running game over Lua UDP, without RCON, so it works in singleplayer.

- Read-only tools: overview, problems, entities, production, power, trends,
  research, recipes, ratio calculator, belt tracer, screenshots, Lua queries
- In-game highlights with clickable [gps] links
- /ask chat command answered by a local helper running claude -p, with a
  per-player model setting and a systemd user service

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
faabandClaude Opus 5.5 committed 2026-10-29 21:28:44 +01:00
commit 1a5cc7426e
13 files changed
+3578

No files matched your search

Whitespace-only changes.
+185
View File
@@ -0,0 +1,185 @@
"""Answers in-game /ask questions by running Claude Code headless with the claude-co-engineer MCP server.
The mod sends {"type": "ask", "id", "player_index", "question"} to FACTORIO_ASK_PORT. We acknowledge it,
run `claude -p` (which uses the CLI's own login, i.e. your subscription), relay which tools Claude is
using as status lines, and send the final answer back through the game's UDP port.
"""
import asyncio
import json
import os
import re
import shutil
import sys
from pathlib import Path
from .game_link import GameError, GameLink
GAME_PORT = int(os.environ.get("FACTORIO_UDP_PORT", "34200"))
ASK_PORT = int(os.environ.get("FACTORIO_ASK_PORT", "34201"))
CLAUDE = os.environ.get("FACTORIO_ASK_CLAUDE") or shutil.which("claude") or "claude"
# Used when the mod doesn't send a model (the in-game "/ask model" setting normally does).
DEFAULT_MODEL = os.environ.get("FACTORIO_ASK_MODEL", "sonnet")
# Wins over the in-game setting, e.g. to try a model the dropdown doesn't list yet.
MODEL_OVERRIDE = os.environ.get("FACTORIO_ASK_MODEL_OVERRIDE")
TIMEOUT = float(os.environ.get("FACTORIO_ASK_TIMEOUT", "300"))
WORKDIR = Path(os.environ.get("FACTORIO_ASK_WORKDIR", "~/.cache/claude-co-engineer/ask")).expanduser()
SYSTEM_PROMPT = """\
The user typed this question into Factorio's in-game chat with /ask, and your final answer is printed
in that chat. Use the factorio tools to look at their game before answering.
Format for the chat window: plain text, no Markdown (no **, #, backticks or tables), at most about
6 short lines. Factorio rich text works and is encouraged: [gps=x,y] for clickable map locations,
[item=iron-plate], [entity=assembling-machine-2], [fluid=water], [technology=automation] for icons.
When you point at places, also call highlight with chat=false (your answer already has the links).
Advise; don't play the game for them.
"""
STATUS = {
"get_overview": "looking around",
"get_player": "checking your inventory",
"inspect_entity": "inspecting that",
"find_entities": "searching the area",
"find_problems": "checking for problems",
"get_production": "reading production stats",
"get_power": "checking power",
"get_trends": "looking at trends",
"trace_belt": "following the belt",
"get_research": "checking research",
"get_recipe": "looking up recipes",
"calculate_ratios": "calculating ratios",
"screenshot": "taking a look",
"highlight": "marking it on your map",
"run_lua": "digging deeper",
}
def strip_markdown(text: str) -> str:
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"^#+\s*", "", text, flags=re.MULTILINE)
return text.strip()
class AskServer(asyncio.DatagramProtocol):
def __init__(self):
self.game = GameLink(GAME_PORT)
self.sessions: dict[int, str] = {} # player_index -> Claude session id
self.locks: dict[int, asyncio.Lock] = {}
WORKDIR.mkdir(parents=True, exist_ok=True)
self.mcp_config = WORKDIR / "mcp.json"
self.mcp_config.write_text(json.dumps({
"mcpServers": {
"factorio": {
"command": sys.executable,
"args": ["-m", "claude_co_engineer.server"],
"env": {"FACTORIO_UDP_PORT": str(GAME_PORT)},
}
}
}))
def datagram_received(self, data: bytes, addr) -> None:
try:
message = json.loads(data)
except ValueError:
return
player = message.get("player_index")
if message.get("type") == "reset":
self.sessions.pop(player, None)
log(f"player {player}: conversation reset")
elif message.get("type") == "ask":
asyncio.ensure_future(self.handle_ask(message))
async def send(self, op: str, **args) -> None:
try:
await self.game.request(op, **args)
except GameError as e:
log(f"couldn't reach the game for {op}: {e}")
async def handle_ask(self, message: dict) -> None:
ask_id, player, question = message["id"], message.get("player_index"), message["question"]
model = MODEL_OVERRIDE or message.get("model") or DEFAULT_MODEL
if not re.fullmatch(r"[\w.-]+", model):
model = DEFAULT_MODEL
log(f"player {player} asks #{ask_id} ({model}): {question}")
await self.send("ask_ack", ask_id=ask_id)
async with self.locks.setdefault(player, asyncio.Lock()):
try:
answer, is_error = await asyncio.wait_for(self.run_claude(ask_id, player, question, model), TIMEOUT)
except TimeoutError:
answer, is_error = f"Gave up after {TIMEOUT:g}s without an answer.", True
except Exception as e: # noqa: BLE001 - report anything to the player rather than dying
answer, is_error = f"The ask helper failed: {e}", True
log(f"answer #{ask_id} ({'error' if is_error else 'ok'}): {answer}")
await self.send("ask_answer", ask_id=ask_id, player_index=player, text=answer, error=is_error)
async def run_claude(self, ask_id: int, player: int, question: str, model: str) -> tuple[str, bool]:
args = [
CLAUDE, "-p", question,
"--model", model,
"--mcp-config", str(self.mcp_config), "--strict-mcp-config",
"--tools", "", # no shell, file or web tools; only the game
"--allowedTools", "mcp__factorio",
"--append-system-prompt", SYSTEM_PROMPT,
"--output-format", "stream-json", "--verbose",
]
if player in self.sessions:
args += ["--resume", self.sessions[player]]
process = await asyncio.create_subprocess_exec(
*args, cwd=WORKDIR, stdin=asyncio.subprocess.DEVNULL,
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, limit=16 * 1024 * 1024,
)
stderr_task = asyncio.ensure_future(process.stderr.read())
result, last_status = None, None
try:
async for line in process.stdout:
try:
event = json.loads(line)
except ValueError:
continue
if event.get("type") == "assistant":
for block in event["message"].get("content", []):
if block.get("type") == "tool_use":
status = STATUS.get(block["name"].removeprefix("mcp__factorio__"), "working")
if status != last_status:
last_status = status
await self.send("ask_status", ask_id=ask_id, text=status + "...")
elif event.get("type") == "result":
result = event
await process.wait()
finally:
if process.returncode is None:
process.kill()
if result is None:
stderr = (await stderr_task).decode(errors="replace").strip()
return f"Claude exited without an answer: {stderr[-300:] or f'exit code {process.returncode}'}", True
if result.get("session_id"):
self.sessions[player] = result["session_id"]
text = strip_markdown(result.get("result") or "")
if "Not logged in" in text or "/login" in text:
return "The claude CLI isn't logged in. Run 'claude auth login' in a terminal, then ask again.", True
return text or "(no answer)", bool(result.get("is_error"))
def log(message: str) -> None:
print(message, file=sys.stderr, flush=True)
async def serve() -> None:
loop = asyncio.get_running_loop()
await loop.create_datagram_endpoint(AskServer, local_addr=("127.0.0.1", ASK_PORT))
log(f"claude-co-engineer-ask listening on 127.0.0.1:{ASK_PORT}, game on {GAME_PORT}, claude = {CLAUDE}")
await asyncio.Event().wait()
def main() -> None:
try:
asyncio.run(serve())
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
+88
View File
@@ -0,0 +1,88 @@
"""UDP request/response link to the claude-co-engineer mod running inside the game."""
import asyncio
import itertools
import json
import socket
from typing import Any
from mcp.server.mcpserver.exceptions import ToolError
class GameError(ToolError):
"""The game couldn't answer a query. A ToolError so the message reaches the model."""
class _Protocol(asyncio.DatagramProtocol):
def __init__(self, link: "GameLink"):
self.link = link
def datagram_received(self, data: bytes, addr: Any) -> None:
self.link._on_packet(data)
class GameLink:
"""Sends JSON requests to the game's --enable-lua-udp port and reassembles chunked replies.
The mod replies to whatever port a request came from, so we bind an ephemeral port.
"""
def __init__(self, game_port: int, host: str = "127.0.0.1", timeout: float = 5.0):
self.game_addr = (host, game_port)
self.host = host
self.timeout = timeout
self._ids = itertools.count(1)
self._pending: dict[int, asyncio.Future] = {}
self._parts: dict[int, dict[int, bytes]] = {}
self._transport: asyncio.DatagramTransport | None = None
async def _ensure_open(self) -> asyncio.DatagramTransport:
if self._transport is None or self._transport.is_closing():
loop = asyncio.get_running_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
# Large replies arrive as a burst of ~32KB packets; give the kernel room to queue them.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8 * 1024 * 1024)
sock.bind((self.host, 0))
self._transport, _ = await loop.create_datagram_endpoint(lambda: _Protocol(self), sock=sock)
return self._transport
def _on_packet(self, data: bytes) -> None:
try:
raw_id, raw_seq, raw_total, fragment = data.split(b"|", 3)
request_id, seq, total = int(raw_id), int(raw_seq), int(raw_total)
except ValueError:
return
future = self._pending.get(request_id)
if future is None or future.done():
return
parts = self._parts.setdefault(request_id, {})
parts[seq] = fragment
if len(parts) < total:
return
del self._parts[request_id]
try:
future.set_result(json.loads(b"".join(parts[i] for i in range(1, total + 1))))
except (KeyError, ValueError) as e:
future.set_exception(GameError(f"malformed reply from game: {e}"))
async def request(self, op: str, **args: Any) -> Any:
transport = await self._ensure_open()
request_id = next(self._ids)
future = asyncio.get_running_loop().create_future()
self._pending[request_id] = future
payload = {"id": request_id, "op": op, "args": {k: v for k, v in args.items() if v is not None}}
try:
transport.sendto(json.dumps(payload).encode(), self.game_addr)
response = await asyncio.wait_for(future, self.timeout)
except TimeoutError:
raise GameError(
f"No reply from Factorio within {self.timeout:g}s on UDP port {self.game_addr[1]}. "
"Check that the game was launched with --enable-lua-udp on that port, the claude-co-engineer "
"mod is enabled, a save is loaded, and the game isn't paused (the Esc menu pauses singleplayer)."
) from None
finally:
self._pending.pop(request_id, None)
self._parts.pop(request_id, None)
if not response.get("ok"):
raise GameError(response.get("error", "unknown error"))
return response.get("result")
+356
View File
@@ -0,0 +1,356 @@
"""MCP server exposing read-only questions about a running Factorio game."""
import asyncio
import functools
import json
import os
import uuid
from pathlib import Path
from typing import Annotated, Any, Literal
from mcp.server import MCPServer
from mcp.server.mcpserver import Image
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
from .game_link import GameError, GameLink
INSTRUCTIONS = """\
You are Claude Co-Engineer, a Factorio advisor connected to the user's live game (Factorio 2.x, possibly Space Age).
The user is playing themselves and wants help deciding what to do and understanding their factory -
answer questions, diagnose bottlenecks, suggest next steps. Do not try to play the game for them.
Start with get_overview when you lack context. Prefer the dedicated tools; use run_lua only for
questions they can't answer, and only to read state - never change entities, inventories, research
or anything else in the world.
Positions are map tiles: +x is east, +y is SOUTH (north is -y). The player can't see coordinates
by default, so describe locations relative to them ("about 40 tiles north-east of you") using
their position from get_overview/get_player. Hovered = the entity under the user's mouse cursor;
when they say "this" or "here" they likely mean the hovered entity or their position.
Whenever your answer points the user at specific places (a broken machine, an ore patch, a nest),
also call highlight so they're outlined in the game and linked in chat - but don't spam it.
Production rates are per minute. Recipe 'seconds' is crafting time at speed 1.
Parameters: omitted x/y mean the player's position (or the hovered entity where that makes sense);
surface defaults to the player's; window is a statistics period (5s to 50h).
"""
READ_ONLY = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
Window = Literal["5s", "1m", "10m", "1h", "10h", "50h"]
game = GameLink(
game_port=int(os.environ.get("FACTORIO_UDP_PORT", "34200")),
host=os.environ.get("FACTORIO_UDP_HOST", "127.0.0.1"),
timeout=float(os.environ.get("FACTORIO_TIMEOUT", "5")),
)
SCRIPT_OUTPUT = Path(os.environ.get("FACTORIO_SCRIPT_OUTPUT", "~/.factorio/script-output")).expanduser()
mcp = MCPServer("claude-co-engineer", instructions=INSTRUCTIONS)
def tool(**kwargs):
"""mcp.tool that returns dict results as compact JSON text. The SDK's default pretty-printed JSON
(plus a structured copy) costs about twice the tokens for the same data."""
def decorate(fn):
@functools.wraps(fn)
async def wrapper(*args, **kw):
result = await fn(*args, **kw)
return json.dumps(result, separators=(",", ":")) if isinstance(result, dict) else result
return mcp.tool(structured_output=False, **kwargs)(wrapper)
return decorate
@tool(annotations=READ_ONLY)
async def get_overview() -> dict[str, Any]:
"""Player, research and broken-machine counts in one call. Start here."""
status = await game.request("ping")
try:
player = await game.request("player")
except GameError as e:
player = {"unavailable": str(e)}
research = await game.request("research")
problems = await game.request("find_problems", samples=0)
return {
"game": {k: status[k] for k in ("tick", "ticks_played", "game_version", "multiplayer", "allow_lua")},
"hours_played": round(status["ticks_played"] / 60 / 3600, 2),
"mods": sorted(status["mods"]),
"player": player,
"research": {
# Lua drops nil fields, so anything optional may be absent.
"current": research.get("current"),
"current_progress": research.get("current_progress"),
"queue": research.get("queue") or [],
"researched": f'{research["researched"]}/{research["total"]}',
"available_now": [t["name"] for t in research["available_now"]],
},
"machine_problems": {
# An empty Lua table serialises as [], hence the `or {}`.
name: {status: p["count"] for status, p in (group.get("problems") or {}).items()} | {"total": group["total"]}
for name, group in (problems.get("machines") or {}).items()
},
}
@tool(annotations=READ_ONLY)
async def get_player(
player: str | None = None,
) -> dict[str, Any]:
"""Position, inventory, cursor, crafting queue, hovered/opened entity."""
return await game.request("player", player=player)
@tool(annotations=READ_ONLY)
async def inspect_entity(
x: float | None = None,
y: float | None = None,
surface: str | None = None,
) -> dict[str, Any]:
"""One entity in full: status, recipe, speed, contents, fluids, fuel, power. Default: hovered."""
return await game.request("inspect_entity", x=x, y=y, surface=surface)
@tool(annotations=READ_ONLY)
async def find_entities(
name: str | list[str] | None = None,
type: str | list[str] | None = None,
radius: Annotated[float, Field(le=1000)] = 32,
x: float | None = None,
y: float | None = None,
force: str | None = None,
limit: Annotated[int, Field(description="Nearest matches listed")] = 15,
surface: str | None = None,
) -> dict[str, Any]:
"""Count entities by name near a point (ore totals for type=resource) and list the nearest."""
return await game.request(
"find_entities", name=name, type=type, radius=radius, x=x, y=y, force=force, limit=limit, surface=surface
)
@tool(annotations=READ_ONLY)
async def find_problems(
radius: Annotated[float | None, Field(description="Default: whole surface")] = None,
types: Annotated[list[str] | None, Field(description="Default: machines, labs, power, turrets")] = None,
samples: Annotated[int, Field(description="Nearest examples per machine+status")] = 3,
include_healthy: bool = False,
surface: str | None = None,
) -> dict[str, Any]:
"""Non-working machines grouped by machine and status (no_ingredients, full_output, low_power...)."""
return await game.request(
"find_problems", radius=radius, types=types, samples=samples, include_healthy=include_healthy, surface=surface
)
@tool(annotations=READ_ONLY)
async def get_production(
window: Window = "10m",
items: Annotated[list[str] | None, Field(description="Default: top movers")] = None,
top: int = 40,
surface: str | None = None,
) -> dict[str, Any]:
"""Produced/consumed/net per minute for items and fluids, plus all-time totals."""
return await game.request("production", window=window, items=items, top=top, surface=surface)
@tool(annotations=READ_ONLY)
async def trace_belt(
x: float | None = None,
y: float | None = None,
direction: Literal["both", "downstream", "upstream"] = "both",
max_tiles: Annotated[int, Field(ge=10, le=1500)] = 300,
section_length: Annotated[int, Field(ge=2, le=100)] = 10,
surface: str | None = None,
) -> dict[str, Any]:
"""Follow a belt (default: hovered): fill, items and motion per section (backed up vs flowing),
inserters/drills on and off it, and where it ends."""
return await game.request(
"trace_belt", x=x, y=y, direction=direction, max_tiles=max_tiles, section_length=section_length, surface=surface
)
@tool(annotations=READ_ONLY)
async def get_trends(
items: Annotated[list[str] | None, Field(description="Default: the biggest movers")] = None,
window: Window = "1h",
buckets: Literal[5, 6, 10, 12, 15, 20, 30, 60] = 12,
top: Annotated[int, Field(ge=1, le=30)] = 8,
power: bool = True,
surface: str | None = None,
) -> dict[str, Any]:
"""Production/consumption (and power) over time as short series with rising/falling/stopped labels."""
return await game.request(
"trends", items=items, window=window, buckets=buckets, top=top, power=power, surface=surface
)
@tool(annotations=READ_ONLY)
async def get_power(
window: Window = "1m",
top: int = 5,
surface: str | None = None,
) -> dict[str, Any]:
"""kW consumed and produced per electric network, by entity."""
return await game.request("power", window=window, top=top, surface=surface)
@tool(annotations=READ_ONLY)
async def get_research(
technology: str | None = None,
) -> dict[str, Any]:
"""Current research and what's researchable now; with technology: its cost, unlocks and missing chain."""
return await game.request("research", technology=technology)
@tool(annotations=READ_ONLY)
async def get_recipe(
name: Annotated[str, Field(description="Recipe, item or fluid")],
) -> dict[str, Any]:
"""Recipes making it (machines, unlocked or not) and recipes using it."""
return await game.request("recipe", name=name)
@tool(annotations=READ_ONLY)
async def calculate_ratios(
item: str,
rate_per_minute: Annotated[float | None, Field(gt=0)] = None,
rate_per_second: Annotated[float | None, Field(gt=0)] = None,
machines: Annotated[float | None, Field(gt=0, description="Final-step machine count instead of a rate")] = None,
inputs: Annotated[list[str] | None, Field(description="Already supplied (e.g. bus plates); not expanded")] = None,
recipe_overrides: Annotated[dict[str, str] | None, Field(description="item -> recipe")] = None,
machine_overrides: Annotated[dict[str, str] | None, Field(description="item/recipe -> machine or drill")] = None,
surface: str | None = None,
) -> dict[str, Any]:
"""Machines, drills, belts and power for a target, from the user's unlocked recipes, machines and
bonuses, vs. what they've built. Use instead of doing ratio math. Give one of the three targets."""
if sum(v is not None for v in (rate_per_minute, rate_per_second, machines)) != 1:
raise GameError("Give exactly one of rate_per_minute, rate_per_second or machines.")
return await game.request(
"ratio",
item=item,
rate_per_minute=rate_per_minute if rate_per_second is None else rate_per_second * 60,
machines=machines,
inputs=inputs,
recipe_overrides=recipe_overrides,
machine_overrides=machine_overrides,
surface=surface,
)
async def _read_finished_jpeg(path: Path, timeout: float = 10.0) -> bytes:
"""The game writes screenshots asynchronously after the tick; wait for a complete JPEG."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while loop.time() < deadline:
if path.exists():
data = path.read_bytes()
if data.endswith(b"\xff\xd9"): # JPEG end-of-image marker
return data
await asyncio.sleep(0.1)
raise GameError(
f"The game accepted the screenshot request but {path} never appeared. Screenshots need a rendering "
"client (a headless server can't take them), and FACTORIO_SCRIPT_OUTPUT must point at the game's "
"script-output folder."
)
@tool(annotations=READ_ONLY)
async def screenshot(
x: float | None = None,
y: float | None = None,
width_tiles: Annotated[float, Field(ge=8, le=1000, description="~30 detail, 60 work area, 200+ whole build")] = 60,
alt_mode: bool = True,
daylight: bool = True,
surface: str | None = None,
) -> list[str | Image]:
"""1280x960 off-screen render of the map for layout questions (~1.7k tokens); returns the area covered."""
name = f"shot-{uuid.uuid4().hex[:12]}"
meta = await game.request(
"screenshot", x=x, y=y, width_tiles=width_tiles, alt_mode=alt_mode, daylight=daylight, surface=surface, name=name
)
path = SCRIPT_OUTPUT / meta.pop("path")
try:
data = await _read_finished_jpeg(path)
finally:
path.unlink(missing_ok=True)
meta["note"] = "+x is east (right), +y is south (down). Pixel (px, py) is at map (left_top.x + px/pixels_per_tile, left_top.y + py/pixels_per_tile)."
return [json.dumps(meta, separators=(",", ":")), Image(data=data, format="jpeg")]
class Marker(BaseModel):
x: float
y: float
label: str | None = None
color: Literal["yellow", "red", "green", "blue", "orange", "cyan", "purple", "white"] = "yellow"
radius: float | None = Field(default=None, description="Ring an area instead of outlining the building")
VISUAL_ONLY = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=False)
@tool(annotations=VISUAL_ONLY)
async def highlight(
markers: Annotated[list[Marker], Field(min_length=1, max_length=20)],
message: str | None = None,
duration_seconds: Annotated[float, Field(ge=0, le=3600, description="0 = until cleared")] = 60,
guide_lines: bool = True,
chat: Annotated[bool, Field(description="Post [gps] links in chat")] = True,
) -> dict[str, Any]:
"""Outline buildings / ring areas in the user's game with labels and guide lines. Visual only."""
return await game.request(
"highlight",
markers=[m.model_dump(exclude_none=True) for m in markers],
message=message,
duration_seconds=duration_seconds,
guide_lines=guide_lines,
chat=chat,
)
@tool(annotations=VISUAL_ONLY)
async def clear_highlights() -> dict[str, Any]:
"""Remove all markers drawn by highlight."""
return await game.request("clear_highlights")
@tool(annotations=ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=False))
async def run_lua(
code: Annotated[str, Field(description="Expression, or statements with return")],
) -> dict[str, Any]:
"""READ-ONLY Lua query for what other tools can't answer. Globals: player, surface, force, game,
prototypes, defines; print() is captured. Never modify game state."""
return await game.request("run_lua", code=code)
def _compact_schema(node: Any) -> None:
"""Drops pydantic's auto titles and null-unions from advertised schemas. Arguments are still
validated by the SDK's own model, which accepts null for optional parameters."""
if isinstance(node, dict):
node.pop("title", None)
if "default" in node and node["default"] is None:
del node["default"]
if "anyOf" in node:
options = [o for o in node["anyOf"] if o != {"type": "null"}]
if len(options) == 1:
del node["anyOf"]
node.update(options[0])
else:
node["anyOf"] = options
for value in node.values():
_compact_schema(value)
elif isinstance(node, list):
for value in node:
_compact_schema(value)
# Private SDK attribute: the registered tools whose `parameters` is what tools/list advertises.
for _tool in mcp._tool_manager._tools.values():
_compact_schema(_tool.parameters)
def main() -> None:
mcp.run()
if __name__ == "__main__":
main()