Skip to main content
Glama
MaddisonM79

factory-resource-manager-mcp

by MaddisonM79

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 server

The 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

session_status

Is the game up? Session info, players, UObject count vs capacity

power_overview

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

factory_problems

Idle / paused / low-efficiency machines, grouped by building + recipe, with overclock / underclock counts and shards / sloops; clock filters on those instead

production_balance

Item production vs consumption, deficits first

find_item

Which containers hold an item, how much, where

logistics_status

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

site_status

One row per site (spatial cluster, default 200 m): machines by state, MW, buildings, recipes, overclocked / underclocked, shards / sloops

belt_load

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

pipe_load

Pipes by tier, and every unconnected pipe end classified as phantom (touching a junction, pump, or machine but not joined) or open

station_throughput

Per station and platform: mode, status, cargo, rates, trains scheduled / inbound / docked

sink_rates

AWESOME Sink coupons, points/min, ETA to next coupon, sink buildings

depot_status

Dimensional Depot per item: stock, capacity, full, fill rate, minutes to full, when it filled

emergency_reserve

Dark-restart readiness: *-EMERGENCY-RESERVE switches (off, full bank behind them) and *-TIE switches (on), the circuit behind each reserve, issues, mode

battery_trend

Battery % and power deltas per circuit over a window; windows beyond 24 h are served from D1 history

trend

Any history series (power, site, gens, depot, prod, station, sinks, drone, counter) over a window, from D1. Same data as /api/series/*

frm_get

Any of the 94 raw FRM 1.5 read endpoints with filter / fields / limit / offset

set_enabled

Toggle buildings by ID. Gated, off by default

frm_write

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.

  • getFactory items: Productivity, IsProducing, IsPaused, IsConfigured, lowercase ingredients / production / location, InputInventory, OutputInventory, PowerInfo.

  • getProdStats: CurrentProd / CurrentConsumed / MaxProd / MaxConsumed.

  • getTrainStation.CargoInventory[] is a list of freight platforms, each with its own Inventory, LoadingMode, LoadingStatus, DockingStatus.

  • getBelts.ItemsPerMinute is the tier cap, not live flow. Measured flow exists only where you build a Throughput Counter: getThroughputCounter gives CalculatedAverage (items/min) and Confidence (0..100) per counter, plus the belt's class and cap but not its ID, so belt_load matches counters to belts by position. Everywhere else it infers problems from the machine a belt connects to.

  • getTrainSignals.Aspect is mislabelled in FRM 1.5.3: the aspect index is looked up in the block-validation enum, so RBV_Valid means Clear, RBV_NoExitSignals means Stop, RBV_ContainsLoop means Dock. The live game sends raw enum names because shipping builds strip display names; trains.ts maps raw and display spellings. BlockValid is correct (RBV_Valid is a properly bounded block).

  • Live getGenerators says Supplement.Name: "N/A" with PercentFull: 0 for generators that take no water; that is not an empty feed.

  • getDroneStation carries 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_status and the drone history series read them.

  • getGenerators: LoadPercentage, Supplement.PercentFull (water for coal and nuclear), WasteInventory and NuclearWarning (nuclear), PowerShards, Somersloops. getFactory: ManuSpeed is the clock in percent, PowerShards, Somersloops.

  • getAll still 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. AvailableFuel lists the fuel types a generator accepts, not its stock. The HUB burners are Build_GeneratorIntegratedBiomass_C.

  • getTrains.Docking is TDS_Docked / TDS_None; TrainStation is 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

power_samples

circuit group

capacity, production, draw, battery, fuse

site_samples

spatial cluster of machines (200 m)

counts by state, MW, productivity, cluster center

gen_samples

(fuel type, generator field)

plus a map-wide row per fuel type with field_id = 0; load_pct, nuclear waste

depot_samples

depot item

stock, capacity, full

prod_samples

item

straight from getProdStats

station_samples

freight platform

mode, cargo, rate, docked train, inbound count

train_visits

dock/undock

opened when a train docks, closed when it leaves; delta_cargo = platform stock at arrival minus at departure

sink_samples

sink

coupons, points to next, points/min

drone_samples

drone port

status, pairing, in/out items per min, est. rate, latest round trip, items per trip, fuel, stocks

counter_samples

throughput counter

belt class and cap, measured items/min, confidence

gap_samples

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 likewise

A 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-only

Dashboard

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.

  1. Tunnel. Create a Cloudflare Tunnel on the server that publishes localhost:8080 to 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 cover a.b.example.com (Worker custom domains get their own certificate, so the mcp. and app. names can be as deep as you like). In FRM's WebServer.cfg set Web_Autostart: true so the server comes up with the save.

  2. 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.

  3. 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 --remote

    Edit wrangler.jsonc: your account_id, the KV and D1 ids, your Worker hostname under routes, and FRM_BASE_URL pointing 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 deploy

    If 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.

  4. 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-server

Security 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=true is set on the Worker. Flip it deliberately, and set FRM's own token too: FRM generates one as uWS.AuthenticationToken in its config and requires it on write endpoints in the X-FRM-Authorization header. Store it as the FRM_API_KEY secret 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 --local

Stack: agents (createMcpHandler), @modelcontextprotocol/server v2, @cloudflare/workers-oauth-provider, hono, zod v4, wrangler v4.

License

MIT. See LICENSE.

Related MCP Connectors

Related MCP Servers

  • F
    license
    A
    quality
    D
    maintenance
    Enables control and inspection of a Factorio 2.0 server via RCON, with tools for administration, player management, game state queries, and Lua execution.
    19
    -
  • A
    license
    A
    quality
    B
    maintenance
    Provides 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.
    22
    10 npm
    AGPL 3.0
  • A
    license
    Not graded
    quality
    C
    maintenance
    Provides status, stats, nearby scans, item analysis, screenshots, ghost placement, in-game replies, and drone status for Factorio via stdio JSON-RPC.
    Apache 2.0
  • F
    license
    B
    quality
    A
    maintenance
    An 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.
    54
    6
    -