factory-resource-manager-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@factory-resource-manager-mcpWhy is my factory short on caterium?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
factory-resource-manager-mcp
A remote MCP server for Satisfactory that lets Claude (or any MCP client) query a running factory through the Ficsit Remote Monitoring mod.
Ask "what's asleep?", "why is caterium short?", "which depot filled first?", "is the battery draining?" and get answers computed from live game data.
It runs as a single Cloudflare Worker: stateless MCP over Streamable HTTP, OAuth in front of it so claude.ai can add it as a custom connector, a cron sampler that writes 5-minute history to D1 so tools can report trends instead of snapshots, and a small dashboard on a second hostname that charts that history and says whether the game is up.
How it fits together
claude.ai / Claude Desktop
│ OAuth bearer token
▼
Cloudflare Worker (frm-mcp) ← this repo
├─ /authorize /token /register workers-oauth-provider, KV-backed
├─ /mcp createMcpHandler + MCP SDK v2
├─ /api/* history read API (same bearer token as /mcp)
├─ cron */5 samples into KV (live, 24 h) and D1 (history)
└─ cron daily OAuth purge + raw → hourly rollup
│ CF-Access service token
▼
Cloudflare Access → cloudflared tunnel → FRM web server (localhost:8080)
on the dedicated serverThe Worker never talks to the game directly. It calls FRM's JSON endpoints through a Cloudflare Tunnel that is protected by a Cloudflare Access service-auth policy. Nothing on the server is exposed to the internet.
Related MCP server: tacticalrmm-mcp-server
Tools
tool | what it answers |
| Is the game up? Session info, players, UObject count vs capacity |
| Per circuit: capacity, draw, headroom, battery, tripped fuses, and its generators (fueled/dry, load %, empty water feeds, nuclear waste and warnings, shards/sloops); plus a map-wide roll-up by fuel |
| Idle / paused / low-efficiency machines, grouped by building + recipe, with overclock / underclock counts and shards / sloops; |
| Item production vs consumption, deficits first |
| Which containers hold an item, how much, where |
| Trains with cargo and errors (including overdue against their own dock cadence), stations per platform, rail signals (aspect, block validity), trucks, drone ports with FRM's rate / round-trip / fuel telemetry |
| One row per site (spatial cluster, default 200 m): machines by state, MW, buildings, recipes, overclocked / underclocked, shards / sloops |
| Belts by tier, dangling ends, belts too slow for the machine they feed or drain, and every Throughput Counter with measured items/min vs the belt's cap |
| Pipes by tier, and every unconnected pipe end classified as phantom (touching a junction, pump, or machine but not joined) or open |
| Per station and platform: mode, status, cargo, rates, trains scheduled / inbound / docked |
| AWESOME Sink coupons, points/min, ETA to next coupon, sink buildings |
| Dimensional Depot per item: stock, capacity, full, fill rate, minutes to full, when it filled |
| Dark-restart readiness: |
| Battery % and power deltas per circuit over a window; windows beyond 24 h are served from D1 history |
| Any history series (power, site, gens, depot, prod, station, sinks, drone, counter) over a window, from D1. Same data as |
| Any of the 94 raw FRM 1.5 read endpoints with |
| Toggle buildings by ID. Gated, off by default |
| Raw POST to any FRM write endpoint. Gated, off by default |
Machine states in site_status are derived: blocked means the output
buffer is full, starved means an input is empty, unpowered means the
circuit has no capacity or a tripped fuse.
What FRM does and doesn't expose
Field names below were verified against a live save; FRM has renamed things
across versions, so if a curated tool looks empty, hit frm_get on the raw
endpoint with limit: 1 and compare keys.
getFactoryitems:Productivity,IsProducing,IsPaused,IsConfigured, lowercaseingredients/production/location,InputInventory,OutputInventory,PowerInfo.getProdStats:CurrentProd/CurrentConsumed/MaxProd/MaxConsumed.getTrainStation.CargoInventory[]is a list of freight platforms, each with its ownInventory,LoadingMode,LoadingStatus,DockingStatus.getBelts.ItemsPerMinuteis the tier cap, not live flow. Measured flow exists only where you build a Throughput Counter:getThroughputCountergivesCalculatedAverage(items/min) andConfidence(0..100) per counter, plus the belt's class and cap but not its ID, sobelt_loadmatches counters to belts by position. Everywhere else it infers problems from the machine a belt connects to.getTrainSignals.Aspectis mislabelled in FRM 1.5.3: the aspect index is looked up in the block-validation enum, soRBV_Validmeans Clear,RBV_NoExitSignalsmeans Stop,RBV_ContainsLoopmeans Dock. The live game sends raw enum names because shipping builds strip display names;trains.tsmaps raw and display spellings.BlockValidis correct (RBV_Validis a properly bounded block).Live
getGeneratorssaysSupplement.Name: "N/A"withPercentFull: 0for generators that take no water; that is not an empty feed.getDroneStationcarries FRM's own transport statistics per port: averaged in/out items per minute, estimated total rate, average / median / latest round trip, items per trip, and the active fuel's cost per trip.logistics_statusand thedronehistory series read them.getGenerators:LoadPercentage,Supplement.PercentFull(water for coal and nuclear),WasteInventoryandNuclearWarning(nuclear),PowerShards,Somersloops.getFactory:ManuSpeedis the clock in percent,PowerShards,Somersloops.getAllstill registers in FRM 1.5 but is retired and answers with an error object, so it is not in the registry.getGenerators:FuelAmount(number),CanStart,ProductionCapacity.AvailableFuellists the fuel types a generator accepts, not its stock. The HUB burners areBuild_GeneratorIntegratedBiomass_C.getTrains.DockingisTDS_Docked/TDS_None;TrainStationis the station the train is at or heading to.No per-building sink rate, no train dwell history, no depot upload rate. The trend tools derive rates from the sampler instead.
The sampler
sink_rates, depot_status, and battery_trend read a ring of snapshots in
KV (samples:ring, 24 hours). A cron trigger samples getPower,
getCloudInv, and both sinks every 5 minutes, and every trend call adds a
sample of its own. The KV ring is the live view; D1 is history.
History (D1)
Every 5-minute tick also fetches getFactory, getGenerators,
getProdStats, getTrainStation, getTrains, getDroneStation, and
getThroughputCounter, and writes one D1 batch:
table | one row per | notes |
| circuit group | capacity, production, draw, battery, fuse |
| spatial cluster of machines (200 m) | counts by state, MW, productivity, cluster center |
| (fuel type, generator field) | plus a map-wide row per fuel type with |
| depot item | stock, capacity, full |
| item | straight from |
| freight platform | mode, cargo, rate, docked train, inbound count |
| dock/undock | opened when a train docks, closed when it leaves; |
| sink | coupons, points to next, points/min |
| drone port | status, pairing, in/out items per min, est. rate, latest round trip, items per trip, fuel, stocks |
| throughput counter | belt class and cap, measured items/min, confidence |
| unreachable tick | the only row written that tick |
Every sample row carries ts, session (FRM SessionName), playtime, and
epoch. The epoch increments when the session name changes or play time goes
backwards (a save was reloaded); no rate, delta, or train visit ever crosses
an epoch boundary or a gap. Raw rows are kept for 7 days; the daily cron rolls
older hours into hourly_* tables (AVG, plus MIN/MAX for running,
blocked, starved, dry, stock) with sample_count and gap_count.
The rollup is idempotent. train_visits and gap_samples are never rolled
up or deleted.
Sites and generator fields are spatial clusters, not stable ids. The sampler
stores cluster centers; the read API resolves them to the sites / fields
lookup tables by nearest center within 200 m. The migration seeds the 11
sites and 5 generator fields of the current save with real centers and
recipe-based names; rename them with
PATCH /api/lookup/sites/:id {"name": "Iron Row"} (x, y, z can be
patched too). A lookup row whose coordinates are NULL is filled in by the
sampler on the next live tick, largest unclaimed cluster first, so a new
site only needs a name. Clusters that match nothing come back with
site_id: null and their raw center.
Read API
Same bearer token as /mcp. All series take from, to (unix seconds,
default the last 24 h) and optional res=raw|hourly; when omitted, raw for
windows of ≤ 7 days inside raw retention, hourly otherwise.
GET /api/status live getSessionInfo + getPlayer + getUObjectCount, plus sampler staleness
GET /api/emergency live dark-restart readiness (?min_charge_pct=95)
GET /api/trains live trains (timetable, cargo, errors, overdue against their own dock cadence), stations (platforms, docked, inbound, scheduled), signals (aspect, block validity)
GET /api/alerts the global alert strip: origin down, sampler behind, object pool, trains (derailed, errors, overdue), invalid blocks, fuses, peak over capacity, dry generators, late rollup
GET /api/latest newest tick from every table, sites/fields resolved to names
GET /api/live KV ring + staleness_seconds (?minutes=)
GET /api/series/power ?group= for one circuit group
GET /api/series/site/:id one site; /api/series/site = every cluster, resolved
GET /api/series/gens?field=:id omit field for map-wide; field=all for every cluster
GET /api/series/depot/:item
GET /api/series/prod/:item
GET /api/series/station/:name
GET /api/series/sinks
GET /api/series/drone/:station /api/series/drone = every port
GET /api/series/counter/:id FRM counter ID (or name); /api/series/counter = every counter
GET /api/visits?station=&train=&from=&to=
GET /api/lookup/sites POST adds a row; PATCH /api/lookup/sites/:id (x/y/z may be null to re-seed)
GET /api/lookup/fields POST / PATCH likewiseA series is { epoch, session, res, points: [{ ts, ... }], gaps: [{ from, to }] };
when the window spans epochs you get an array of them. Hourly points add
sample_count and gap_count; treat gap_count > 0 as low confidence.
Only /api/status, /api/emergency, /api/trains, /api/alerts and the admin routes
reach the FRM tunnel; everything else is served from KV and D1.
Overdue trains. FRM reports a train deadlocked at a path signal as
healthy: speed 0, docking None, autopilot and path NoError. The tell is
time since its last recorded dock. /api/trains, /api/alerts and the
logistics_status tool read each train's train_visits over the last 24 h
and flag it once it has gone twice its median dock interval (floor 30 min;
a flat hour with fewer than three intervals) without docking. Sampler gap
ticks after the last dock are subtracted, so an origin outage does not
count against the train; docked, derailed, manually driven and
timetable-less trains are never overdue.
Admin API (dashboard host only)
Mounted behind the Hanko session and allow-list, never behind the OAuth bearer: a connector token must not be able to list or revoke other connectors.
GET /api/admin/system vars and gates: origin, hosts, allow-list, write tools, API key, Access token
GET /api/admin/origin live reachability with latency, FRM version, mod list
GET /api/admin/sampler KV state and ring, D1 table sizes, epochs, recent gaps, rollup status
GET /api/admin/oauth OAuth clients, grants (every user id), live token counts
DELETE /api/admin/oauth/grants/:user/:id revoke a grant and its tokens
DELETE /api/admin/oauth/clients/:id delete a client and revoke its grants
GET /api/admin/game session, players, power switches, chat (?chat=N), object pool; read-onlyDashboard
app.<zone> serves src/web/static/: one page, plain ES module, uPlot for charts,
no build step. An alert strip under the tabs (from /api/alerts, polled
every minute while the page is visible) lists what needs a human on every
tab, worst first, and each entry jumps to its tab. It shows whether the game answered just now (/api/status
calls getSessionInfo and getPlayer live, the one place under /api that
reaches the tunnel), then tabs for power per circuit group, item production
vs consumption, sites by machine state, generator fields (with load % and
nuclear waste), the depot, and the
sinks, an Emergency tab for the dark-restart reserves, and a Trains tab
(every train with its timetable, cargo and errors; every station with its
platforms, what is docked or inbound and which trains schedule it; the dock
history from train_visits with dwell and cargo moved; a station's transfer
rate over time; every signal at Stop or on an invalid block, with the full
list behind a toggle). An Admin tab covers the stack itself: origin
reachability and latency with the FRM version and mod list, the Worker's
configuration and gates, sampler state with D1 table sizes, epochs, gaps
and rollup status, OAuth clients and grants with revoke, site and field
lookup editing, and a read-only game panel (session, players, switches,
chat). Every chart takes the same time range (1 h to 30 d) and a Local / UTC
toggle in the header; outages are shaded, save reloads are marked with the
session name, and nothing is interpolated across either. Tables come from
/api/latest, the newest tick from every history table with sites and
fields resolved to their lookup names.
Emergency reserves are a naming convention. A power switch named
<SITE>-EMERGENCY-RESERVE is expected off with a full battery bank behind
it; <SITE>-TIE is the site's cut-off from the main grid, expected on.
A dark restart turns the ties off and the reserves on. emergency_reserve
and /api/emergency pair them by site and report every deviation: a reserve
that is on, a bank below the threshold, a discharging or loaded reserve side,
a tripped fuse, a tie that is off. A switch not built yet, or a side with no
cable, is a note rather than an issue. Switch state is always on/off (FRM's
IsOn); "open" is avoided because it means opposite things to different
people. The circuit topology is a cross-check: an off switch whose two sides
still resolve to one power group is bypassed by another cable path and does
not isolate anything by itself, which is reported as a note.
Sign-in is Hanko: the login element stores its JWT in a
first-party hanko cookie, the Worker verifies it against the project's JWKS
(jose) and then checks the email claim against DASH_ALLOWED_EMAILS. A
valid Hanko session for anyone else is a 403, so turning registration off in
the Hanko project is belt and braces, not the only lock. MCP_HOSTS and
DASH_HOST in wrangler.jsonc decide which hostname gets which app; the
dashboard host never touches the OAuth provider.
Deploying your own
You need: a Cloudflare account with a zone, a Satisfactory dedicated server
(or the game itself) with FRM installed, and cloudflared on that machine.
Tunnel. Create a Cloudflare Tunnel on the server that publishes
localhost:8080to a hostname on your zone, e.g.frm.example.com. Keep it a first-level subdomain: the tunnel's CNAME relies on Universal SSL, which does not covera.b.example.com(Worker custom domains get their own certificate, so themcp.andapp.names can be as deep as you like). In FRM'sWebServer.cfgsetWeb_Autostart: trueso the server comes up with the save.Access. In Zero Trust, create a service token, then a self-hosted Access application for that hostname with one policy: action Service Auth, include Service Token = your token. Verify with curl that a request without headers gets 403. A token alone protects nothing until an application references it.
Worker. Clone this repo, then:
npm install npx wrangler kv namespace create OAUTH_KV # put the id in wrangler.jsonc npx wrangler d1 create frm-history # put the database_id in wrangler.jsonc npx wrangler d1 migrations apply frm-history --remoteEdit
wrangler.jsonc: youraccount_id, the KV and D1 ids, your Worker hostname underroutes, andFRM_BASE_URLpointing at the tunnel hostname. Then:npx wrangler secret put CF_ACCESS_CLIENT_ID npx wrangler secret put CF_ACCESS_CLIENT_SECRET npx wrangler secret put ADMIN_PASSPHRASE # long and random; you type it once per client npx wrangler deployIf you keep secrets in 1Password, pipe them in so they never hit a terminal:
op read 'op://Vault/item/field' | npx wrangler secret put NAME.Connect. In claude.ai, Settings → Connectors → Add custom connector →
https://<your-mcp-host>/mcp. It redirects to the passphrase page; enter it once and you're done. Dynamic client registration handles the rest, no client id or secret to copy.
Smoke test without a client:
curl https://<your-worker-host>/.well-known/oauth-authorization-serverSecurity model
The server only exposes FRM on localhost; the tunnel is the only path in.
"FRM origin unreachable" errors name the failing layer: 502 means cloudflared answered but FRM is not listening (server down or mod not loaded); 530 means the tunnel has no connection (cloudflared stopped or the machine offline).
Cloudflare Access rejects anything without the service token before it reaches the tunnel.
The Worker holds the service token as a secret and adds it to every origin request. Clients never see it.
The MCP endpoint requires an OAuth bearer token issued by the Worker. Issuing one requires the passphrase, compared in constant time.
Write tools are refused unless
FRM_ALLOW_WRITE=trueis set on the Worker. Flip it deliberately, and set FRM's own token too: FRM generates one asuWS.AuthenticationTokenin its config and requires it on write endpoints in theX-FRM-Authorizationheader. Store it as theFRM_API_KEYsecret and the Worker sends it on every POST.Service tokens expire (default one year). When yours does, rotate it in Zero Trust, re-put both secrets, and update cloudflared if it shares the token.
Development
npm run typecheck
npm test # node:test + node:sqlite standing in for D1; no extra deps
npx wrangler dev # needs a .dev.vars with the secrets above
npx wrangler d1 migrations apply frm-history --localStack: agents (createMcpHandler), @modelcontextprotocol/server v2,
@cloudflare/workers-oauth-provider, hono, zod v4, wrangler v4.
License
MIT. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Query Allen-Bradley and Siemens PLC projects, live tag values, and analyses in plain English.
Analytics for MCP servers. Query your tool calls, first-call success, retries and schema cost.
Read-only VNISH firmware metadata, Ninja guidance and input-based mining economics calculations.
Search your AI chat history (ChatGPT, Claude, Codex) from any MCP client. Remote, private, read-only
Related MCP Servers
- FlicenseAqualityDmaintenanceEnables control and inspection of a Factorio 2.0 server via RCON, with tools for administration, player management, game state queries, and Lua execution.19-
- AlicenseAqualityBmaintenanceProvides read-only access to TacticalRMM remote monitoring and management instances via the MCP protocol. It enables querying agents, clients, alerts, checks, and other RMM data through natural language.2210 npmAGPL 3.0
- AlicenseNot gradedqualityCmaintenanceProvides status, stats, nearby scans, item analysis, screenshots, ghost placement, in-game replies, and drone status for Factorio via stdio JSON-RPC.Apache 2.0
- FlicenseBqualityAmaintenanceAn MCP server for Satisfactory that plans factories using your actual save files, reads game data, and runs LP/MILP optimization. It provides tools for game data lookup, factory planning, hard drive advice, and a local web map of your world.546-