Skip to content

The MCP server

source-rpc mcp --broker mqtt://localhost:1883
source-rpc mcp --hub http://hub:7843

Serves the network to an MCP client over stdio, so a model can look at a plant the way a person looks at the console. It takes the same network flags as console, including --sign. --port opens a second door over streamable HTTP, so a second client can attach to the same node. If the MQTT broker requires a login, keep --broker as the broker address and set SOURCE_RPC_MQTT_USERNAME and SOURCE_RPC_MQTT_PASSWORD in the environment rather than putting the password in the command line.

toolwhat it does
list_peerswho is on the network right now
describe_peerone peer's namespaces, methods, argument names and types, and events
call_methodcall a method, with positional arguments, and return what it returns
read_statewhat a component is publishing — its props and state, as values
set_statechange one value, by naming the path rather than working out the method
list_writablewhat a store-backed node will let you change, asked before you are refused
read_rowone row, and the stamp that names the state it was read in
write_rowcreate, update or delete one row, under the stamp you read it with
read_contextwhat a node inherits along its physical or logical chain
start_fakestand a peer up from a contract and put it on this network
stop_fake / list_fakestake one off again; what is being served here
check_peercompare a live peer with a contract and report what would break
diff_peerswhat two live peers expose differently
watch_trafficwhat other peers are saying to each other, for a few seconds
watch_eventswhat one peer emitted, for a few seconds — and whether anything was missed since the last watch
save_contract / list_contractsonly with --contracts <dir>

Reading what a device holds

describe_peer gives the shape of a peer and call_method is how it is changed. read_state is the third question, and on a plant usually the first one anybody asks: what is it right now.

read_state { peer: "bakery", namespace: "oven" }
→ { "component": "bakery.oven", "status": "live", "epoch": "…", "revision": 41,
    "receivedAt": "2026-08-07T18:22:10.114Z",
    "props": { "unit": "°C", "maximum": 300 },
    "state": { "celsius": 184, "setpoint": 180, "mode": "heating", "door": "closed" } }

Both halves come back, because they answer different questions: props are the host's inputs — what this oven is, its unit and its ceiling — and state is what it is doing. describe_peer marks which namespaces are components; one that is not cannot be read this way, and says so.

Looking leaves nothing behind. It subscribes, takes the first snapshot and drops the subscription again, the same rule watch_events follows — so a model that reads a device twenty times does not end up as twenty observers on it. A snapshot that never arrives is bounded too: the subscribe is covered by the call timeout, but a host that accepts it and then publishes nothing would otherwise hold the tool call open forever, and that host is precisely the one somebody is using this to diagnose.

A stale picture comes back with its values, marked stale with a staleSince, rather than withheld. "20 °C, last known 14:03" is an answer and a blank is not — the same judgement the store makes for every other observer.

Changing one value

set_state names the path, not the method:

set_state { peer: "bakery", namespace: "oven", path: "zones.top.setpoint", value: 180 }
→ { "called": "oven.setTopSetpoint", "path": "zones.top.setpoint", "returned": 180,
    "note": "The method ran. Nothing was written locally - use read_state to see what the
             component published next." }

Working out which method is the whole job, and it is done from sets rather than from what the methods are called. A per-field claim wins over a generic sets: '*' where both exist, because the specific method is the one whose body was written for that value — with whatever clamp and interlock belong to it.

A path nothing claims is refused with what the component does set beside it, since "no" is more useful with the alternatives next to it, and a measured value having no setter is a design decision rather than a gap:

set_state { …, path: "temperature", value: 300 }
→ Nothing on bakery.oven declares that it sets 'temperature'. It does set:
  setpoint (setSetpoint), mode (setMode), door (setDoor), zones.top.setpoint (setTopSetpoint).

A wrong type is refused before it travels. The component's state interface is published in the contract, so the type at a path is known here — setpoint = "hot" fails locally as InvalidParams rather than being discovered by a plant.

What happens is the method's call, not a write. The component may clamp it, refuse it while the door is open, or fail an interlock, and that refusal comes back as the component's own words. Nothing is written locally either, so the answer says so: the value moves when the peer publishes its next snapshot, and read_state is how to see what the plant actually agreed to. A model that assumes its write took effect is a model that has stopped reading the plant.

A component declaring sets: '*' claims every path by construction, so on one of those the refusal for an unwritable path comes from the method's body rather than from here — which is where that decision belongs.

Changing rows in a store

A store-backed node — a database, a document store — is the other thing a model gets asked to edit while prototyping, and the three tools that do it are shaped around one decision: every change carries a precondition, and the precondition has to come from a read the model actually did.

list_writable is the first call, and it exists so that finding out what may be changed does not mean generating a refusal for somebody to explain later:

list_writable { peer: "records", namespace: "sql.write" }
→ { "node": "records.sql.write",
    "resources": [ { "resource": "pupils", "verbs": ["create", "update", "delete"],
                     "columns": ["name", "grade"],
                     "row": { "kind": "object", "fields": { … } } } ] }

A resource absent from that list is one nobody allow-listed, not one that does not exist — worth saying in those words, because "why can I not edit orders" has two answers and only one of them is a spelling mistake. An empty list is a node that accepts no writes at all, which is what a node stood up without a decision behind it should be. The columns are what create and update accept rather than what the row holds, so a form drawn from them offers nothing the next call refuses.

read_row answers a row and its stamp:

read_row { peer: "records", namespace: "sql.write", resource: "pupils", id: "1" }
→ { "resource": "pupils", "id": "1", "status": "ok",
    "row": { "id": "1", "name": "Ada", "grade": 3 },
    "stamp": "kQ7hV…",
    "note": "The stamp names the state this row was read in. Pass it back to write_row…" }

The stamp names the state that row was read in, and the only way to hold one is to have read the row. It belongs to that row in that resource and to no other — it cannot be carried across from a sibling, lifted out of a listing, or invented.

write_row requires the stamp for update and delete, and never fetches one itself. That is the centre of the design rather than a rough edge on it. A tool that read the row and immediately wrote it back would satisfy the precondition by construction: the compare would be against the state it had just fetched, a microsecond earlier, which is a compare-and-set comparing against itself — and the lost update the precondition exists to prevent would pass every single time, silently, leaving nothing anywhere for anyone to find. So the loop is read, decide, write, and the deciding is the part that has to happen between the two calls, because it is the only part that can notice the row is no longer the one the change was designed for.

Which is why a conflict is a result rather than an error, and why the answer says what to do with it:

write_row { …, verb: "update", id: "1", row: { "grade": 4 }, stamp: "kQ7hV…" }
→ { "resource": "pupils", "id": "1", "verb": "update", "status": "conflict",
    "note": "Nothing was written. The row changed between the read that produced this stamp and
             this call… Do not retry — read_row again, see what somebody else did, and decide
             again." }

The correct response to a conflict is not a retry. A retry with the same stamp fails again; a retry with a stamp taken now is the blind overwrite the whole mechanism exists to prevent, performed in two calls instead of one. The conflict carries no new stamp for exactly that reason — handing one back would put that overwrite a single call away, and a model in a hurry would find it. missing is answered the same way and for the same reason: the row is gone, so resending will not find it, and re-reading is what tells you whether it was removed or never there.

On ok the row's new stamp comes back, so a second edit needs no read between it and the first. And, the way set_state's answer already says for a component, nothing is written locally: what the store agreed to is what the next read_row reports, since a default, a trigger or a type conversion may have had an opinion on what was sent.

The argument shapes are refused rather than half-applied, each with what was expected: create takes a row and neither an id nor a stamp — the row has no prior state to make a precondition of, and the store names what it inserts; update takes a patch of the fields being changed, where a field the resource does not permit is refused rather than quietly dropped; delete takes an id and a stamp and no row at all. There is no bulk verb and there is not going to be one — fifty rows are fifty calls, each with its own precondition, each individually refusable, each individually visible in an audit line.

What a node inherits

read_context asks what one node sees of the ambient data on its chain — the site it stands in, the work order it is running, the maintenance window that applies to it — resolved by that node's own host rather than worked out by the MCP server:

read_context { peer: "bakery", node: "oven", token: "acme.site", axis: "physical" }
→ { "node": "bakery/oven", "token": "acme.site", "axis": "physical", "status": "live",
    "entries": [ { "provider": "bakery/$host",
                   "value": { "site": "site-7", "plant": "bakery", "timezone": "Europe/Stockholm" } } ] }

The whole chain comes back, nearest provider first, each entry naming where it was provided — which is the part that answers the question this is usually opened for, namely why a machine is behaving as though it sat somewhere else. Underneath it is contextAt(), the library's own resolver begun one hop out: the same chain walking, the same cycle and depth checks, the same lifecycle vocabulary. A tool that quietly disagreed with the library about what a node sees would be worse than no tool at all.

The axis is asked for rather than guessed, because it belongs to the token's definition and the caller has not seen that definition. There is no logical-first-then-physical search anywhere in this library, so the wrong axis answers missing rather than borrowing from the other one.

missing, stale and invalid are answers about the plant, not failures of the tool — invalid carries the reason and the path, so a cycle names the nodes it closes on.

There is no tool that lists the tokens a plant carries, and there will not be. Context has no enumeration surface by design: listing what ambient data a plant holds is reconnaissance of a sharper kind than listing methods, and a provider that declares exposure: 'local' is filtered from remote answers silently rather than refused, because a refusal would confirm it exists. So a token nobody provides and a token deliberately kept local look identical from outside — both missing — and a caller has to already know the id it is asking for. The tool description says so, or a model spends a turn hunting for a list and concludes the tool is broken.

Standing something up

The awkward part of asking a model to test a device is that the device has to exist. start_fake takes a contract inline — no file, no shell, no second terminal — and puts a peer on the network that answers from it:

start_fake { name: "fakePlant", schema: {…}, script: { returns: { "plant.read": { celsius: 84 } } } }
→ fakePlant is on the network, answering plant from the contract. It is a fake: it answers from
  the contract, not from a device.

From there the ordinary verbs reach it, and it refuses what the contract refuses — so a model can check that its caller handles InvalidParams without touching anything real. script supplies canned returns, deliberate failures and timed events, including the Timeout code that never answers at all.

A fake will not take a name a peer already answers to. Standing one up under a live device's name would displace it, and calls meant for the plant would reach a stand-in that agrees with everything. That is refused, not resolved.

Fakes run inside the MCP server rather than as spawned processes, so they stop when it does and none are left behind.

A second door: streamable HTTP

stdio means exactly one client, and that shape has a failure the first field trial lived rather than theorized: an MCP node already attached to one session, a second agent unable to use it, and the fallback — driving the CLI by hand — meant the node nominally custodian of the scripts directory never knew about the script running from it. stop_script and script_output were dark, and the scripts state forked.

source-rpc mcp --hub http://bus:7843 --scripts ./scripts --port 8590

--port serves streamable HTTP beside stdio: a POST carrying one JSON-RPC message, a JSON answer. Every client — stdio and however many attach over HTTP — shares one view of the scripts, the fakes, the watches and their loss cursors, because there is only one of everything in the process; that is the reason the door exists, and why it deliberately assigns no session ids. There is no server-initiated stream (GET answers 405) and no batching.

The bind follows the console's instinct: 127.0.0.1 unless --host widens it, and access control was designed before the port opened, because an HTTP door is a new surface where stdio's authorization was implicit in process ownership. The token comes from SOURCE_RPC_MCP_TOKEN or --mcp-auth <file> — never a flag value, since ps is readable by everyone on the box — and the rules are stated at startup and fail closed where it matters: a door widened past loopback without a token refuses to start, and a loopback door without one says plainly that any process on this machine can drive the node.

Watching without wondering

watch_events subscribes for a few seconds and drops the subscription again, so a look leaves no listener behind on the device. That shape has a blind spot: an agent waiting for something rare polls windows, and an event can fall between them. So the answer carries a loss verdict, computed from an emission counter the server keeps per event whether or not anyone is subscribed — "gapless" means nothing fired between this watch and the previous one that was not heard; "missed N" means N fell in the gap; a server restart between watches is reported as unknowable, because a fresh incarnation cannot say what an old one dropped — the counter's vocabulary is the component channel's epoch discipline, and a sequence only orders within one epoch. A peer running an older library, or serving no introspection, is reported as unable to say, which is different from either. Each heard event also carries its seq, so a gap inside a window is visible in the data itself.

Where contracts go

--contracts <dir> is what makes save_contract and list_contracts exist at all. Without it they are not in the tool list, because a server that cannot write files should not advertise tools claiming it can. With it, a contract is written as <name>.types.json in that directory and nowhere else — a name that would climb out of it is refused rather than resolved — and what is written is the same file source-rpc serve --contract and source-rpc check --peer --against read.

So the loop closes: a model can draft a contract, save it where the CLI will find it, stand a peer up from it, drive that peer, and check a real device against the same file.

Peers kept as scripts

start_fake is the two-minute answer, and it is gone when the conversation ends. --scripts <dir> is the other thing: a directory of peers written as programs, which the model can add to, change, start and stop — and which you can open in an editor, commit, and run by hand with node.

A script is not bound to one contract, so unlike a fake it can call as well as answer: drive a start-up sequence, poll a device and log what it sees, bridge two networks, or stand several peers up at once.

source-rpc mcp --hub http://bus:7843 --scripts ./scripts
tool
save_scriptwrite <name>.ts (or .mjs) to the directory
read_scriptits source, for changing part of it rather than rewriting the whole
list_scriptswhat is there, and which of them are running
start_script / stop_scriptrun it as its own process, or stop it
delete_scriptremove it, stopping it first if it is running
script_outputthe last 200 lines it printed, stderr marked !

TypeScript by default, and Node runs it directly — no build step and no loader, on Node 22.6 and later. That is the point rather than a convenience: this library's whole idea is that a class is the contract, so a script that says import type { Pump } from '../plant.js' gets the same typed proxy the rest of your code does. On an older Node, or for a script that would rather be plain JavaScript, save it as mjs.

Each script is its own process. So it can import whatever it likes; a script that throws or wedges cannot take the MCP server down with it; and starting and stopping are a spawn and a kill rather than a module cache to reason about. They are stopped when the server exits, rather than left holding peer names nobody is serving.

The network is handed over, not hardcoded. A script is started with SOURCE_RPC_HUB, SOURCE_RPC_BROKER, SOURCE_RPC_PREFIX and SOURCE_RPC_TOKEN set from the flags this server was given, so it reads its broker url rather than carrying one that is right on your machine and wrong on the next. Its working directory is the scripts directory, so relative imports mean what their author meant and @source-repo/rpc resolves from the project that directory sits in.

That resolution is also where a sandbox quietly ages: a directory that pinned the library a major ago keeps working — old scripts against their own pinned dependency are legitimate — but new code written there is written against the old API, and nothing fails to say so. So mcp and node print one line at start when the directory's @source-repo/rpc major differs from the CLI's, naming both versions. A statement, never a refusal; matching majors print nothing.

typescript
// scripts/pump-sim.ts — started with start_script, or `node scripts/pump-sim.ts`
import { RpcServer } from '@source-repo/rpc'

const peer = new RpcServer({ name: 'pumpSim', transports: [{ connect: process.env.SOURCE_RPC_HUB! }] })
peer.exposeClassInstance(new Pump(), 'plant')
await peer.ready()
console.log('pumpSim is on the network')

Scripts get their own dependencies. A script is an ordinary Node program, so sooner or later one wants something off the registry — a date library, a CSV parser, a driver for whatever is on the other end of the serial port.

tool
list_packageswhat is declared, and what is actually installed
add_packageinstall one into the scripts directory
remove_packageuninstall it

The directory gets its own package.json and node_modules, so nothing is added to the project around it, and a script resolves its imports from next door. That manifest also carries "type": "module" — which a .ts script needs, because Node decides whether import is legal from the nearest manifest, and inside a CommonJS project it would otherwise warn on every run and put the warning in the script's own output.

Install scripts are skipped by default. A postinstall hook is unreviewed code from the registry running on your machine, and it is the part of npm install that is not about files at all. A package that genuinely needs one — anything with a native build — takes allowInstallScripts, and the asking is visible in the tool log.

This is not a new grant on top of --scripts: a script could already child_process.exec('npm i …') by itself. What the tools buy is that the dependency is declared — in the tool log and in a committed package.json — rather than acquired sideways.

This is a bigger grant than --allow-exec, and separate from it on purpose. A handler body runs in a context with no filesystem and a time budget; a script is an ordinary Node process with your privileges, which can open sockets and read your disk. Both are development-machine features, and neither is enabled by default in the container — the image that has what they need is the -dev one.

Two images

The published image comes in two flavours, because the CLI is two things.

ghcr.io/source-repo/rpc-cliwhat goes near a plant: the commands that are infrastructure, and nothing else in the image
ghcr.io/source-repo/rpc-cli:devthe same CLI plus npm and python3, which --scripts and the Python half of --allow-exec need in order to work at all
docker run --rm -v "$PWD/scripts:/scripts" ghcr.io/source-repo/rpc-cli:dev \
    mcp --hub http://bus:7843 --scripts /scripts

The bare name is the runtime one, and latest points at it — whoever pulls without thinking should get the image meant for a plant.

The difference is not cosmetic. The runtime image has no fixable critical or high vulnerabilities; the development one carries five, all from npm's own bundled dependencies (tar, undici, brace-expansion). Shipping npm means inheriting every advisory against them, which is the whole reason the runtime image drops it — nothing at runtime shells out to npm, and the CLI is installed by the time it goes. The release scans both: the runtime image blocks a release on anything fixable, the development image is reported and not enforced, because failing on npm's dependencies would mean never releasing.

Neither flag is on by default in the -dev image either. It can do these things when asked; started with no flags it behaves exactly like the runtime one.

One practical note: add_package installs into the scripts directory rather than globally, so a bind-mounted directory has to be writable by uid 1000, which is the node user both images run as.

Not one tool per method on the network. A peer set that changes while a model is mid-conversation would mean re-issuing the tool list on every arrival and departure; describe_peer hands over the argument types instead, which is the same information in a form that does not go stale.

A call a peer refuses comes back as tool content with isError, carrying the reason — InvalidParams: argument 0: expected number, got string — rather than as a JSON-RPC failure. A model should read that and fix its call, which it cannot do if the transport swallows it.

To wire it into a client, give it the command and its flags:

json
{
  "mcpServers": {
    "plant": { "command": "source-rpc", "args": ["mcp", "--broker", "mqtt://localhost:1883"] }
  }
}

stdout carries the protocol and nothing else, so this is not for interactive use — startup goes to stderr, and a stray console.log anywhere in the process would corrupt the stream. There is no MCP SDK behind it: MCP is JSON-RPC 2.0 over newline-delimited stdio, which is little enough to speak directly, and this package is about not needing a second RPC framework.

Anything a model can reach, it can call. The peers this lists are real, and call_method will happily invoke one that opens a valve. Point it at a network where that is acceptable, or give it credentials that restrict it: --sign makes it a peer with an identity, and authorize on the servers decides what that identity may do.

Reading is reading, and it is still governed. read_state and read_context change nothing — but they do carry plant values back to a model, which describe_peer alone never did. They are ordinary subscriptions and ordinary $context calls underneath, so authorize() sees them with the namespace, the node and the token ids in view, and a server that refuses this peer refuses these too. There is no read that bypasses it, and no way to enumerate what a peer holds without already knowing what to ask for.

set_state is call_method with the method worked out, and no more permitted than it. It calls a method the peer declared, through the same dispatch, with the same authorize() and the same effect classification — so a grant that refuses oven.setSetpoint refuses this too, and one that permits it permitted this already. It adds convenience, not authority. What it does add is a reason to be deliberate about which network this server points at: naming a path is a much easier thing for a model to do than working out a method, which is the point, and also the risk.

list_writable, read_row and write_row are call_method with the method worked out, and no more permitted than it. call_method can already invoke sql.write.update on any peer this server can reach; these call the same methods, through the same dispatch, with the same authorize(), the same effect classification, and the same allow-list on the node deciding which resources and fields exist at all. So there is no flag that turns them on: a flag would advertise a restriction that does not exist here, while the restriction that does exist lives on the node being written, in a permission document a reviewer can diff. What they add is that a model can find out what it may change before it tries, and cannot change a row it has not read.

And it can put peers on that network. start_fake adds one — it calls nothing and changes no device, and it refuses a name already in use, but it is a peer other things can find and call. The same authorize and --sign machinery governs what it may do once it is there. Writing files is the one capability that stays off unless asked for: no --contracts, no tools that write.

MIT licensed