Report privately through GitHub's private vulnerability reporting on this repository. Please do not open a public issue for a suspected vulnerability.
Include what you can: the tool or CLI involved, a configuration that reproduces it (with any site-specific host or PV name replaced by a placeholder), and what an attacker would gain. This is a pre-1.0 project with a single maintainer, so expect an acknowledgement rather than a service-level guarantee.
Only main is supported. There is no backport branch, and pre-1.0 minor versions may carry
breaking changes.
The server is built to be safe to point at a control system without a separate review of every setting.
- Read-only by default. Every mutating tool is off unless explicitly enabled.
set_pv_valueneedsEPICS_MCP_ALLOW_PV_WRITE=trueand a non-empty regex allowlist of writable PV names and a per-minute rate limit. Writes enabled with an empty pattern makes the server refuse to start, rather than silently permitting every PV. The fullest statement of this posture, including what leaves your machine on every plane, is docs/safety.md; this page is the security-review view of it. - Two independent write gates. The Olog logbook gate (
EPICS_MCP_ALLOW_OLOG_WRITE) is separate from the PV gate, with its own allowlist, its own rate limit and its own URL boundary. Enabling one never enables the other. The two are not the same SIZE either: the PV gate is three checks, the Olog gate six checks. What the Olog gate has on top is a non-empty target-logbook list and a test-server URL boundary and an attachment size cap. That group is spelled as this list rather than as a figure, andtests/test_gate_lists.pycounts it against the difference the gate modules actually have. - PV writes require a loopback-only search reach. A server with
EPICS_MCP_ALLOW_PV_WRITE=truewhose EPICS client search environment can reach beyond loopback refuses to start. "Read the facility and write the facility" is a start-time impossibility here, not a matter of discipline. The check reads the reach with the same parser the real client uses and never trusts a hostname as loopback.⚠️ PV, not both gates, and the distinction is measured rather than assumed: this refusal and the empty-allowlist one are conditions of the PV gate alone, while the durable audit path is required by both. An Olog-write-enabled server starts with the subnet broadcast search on; its own boundary is on the write TARGET (loopback, or an exactly allowlisted https URL), not on the search reach. - Every sanctioned write is bounds-checked and read back. The value is checked against the
record's own drive limits before the put; an out-of-range value is refused before it reaches the
IOC. After the put, the server reads the value back and reports whether it landed, so a silent
wrong write surfaces as
verified=falseplus an audit line. - Mandatory, metadata-only audit. A write-enabled server refuses to start unless a durable audit path is configured, because an audit nobody can read after the process exits is a promise rather than a record. The audit carries identifiers, the writing principal and bounded scalars. It never carries free text (a title/description body, a filename).
- No network reach without configuration. Each optional REST plane (ChannelFinder, Archiver,
Alarm, Naming, Olog) stays disabled until its
*_URLis set: unset means no client and no network call. PV READ reach follows the standard EPICS search environment, which the launcher controls and this server does not. PV WRITE is the exception: enabling it forces a loopback-only reach and the process refuses to start otherwise, so that reach is not the launcher's to widen (see above). Runepics-doctorto see what an instance actually reaches, and to see the effective write posture of both gates: itsWrite gatesblock gives a gate that is OFF, the default for both, one line saying so, and an ARMED one what it allows and where a write could go.--jsoncarries the same underwrite_safety, every field present either way. It reads the environment of the command you run, which need not be the one a running server was started with. - The server log is deliberately unredacted, and it is a different channel from the answer. An
unexpected internal error tells the caller only the exception's class name and puts the full
message and traceback in the server log, so the bug stays debuggable. That detail can carry a
service URL as configured, credentials included. The one measure that removes them from this
channel is putting credentials in
EPICS_MCP_*_AUTHrather than in a*_URL; a log level will not, because the line that carries them is anERROR. Where that log goes is not this server's decision either: it speaks stdio unlessFASTMCP_TRANSPORTsays otherwise (see below), so its stderr belongs to whatever launched it.docs/safety.mdstates the exposure with the three qualifications that bound it.⚠️ That measure is unavailable on one plane. Four planes have anEPICS_MCP_*_AUTHvariable (ChannelFinder, Archiver, Alarm, Olog). The Naming plane has none, so a credential it needs can only live in its*_URL, and for that plane the sentence above has no remedy to offer. - The ANSWER is a second channel, and it is closed. Everything above is about the log. The
other route out of this process is what a tool RETURNS, and it used to carry the same value: a
REST failure message named the whole request URL, and two tools put such a message into a
noteof a payload they returned SUCCESSFULLY, which a client keeps (diagnose_connectionandlookup_device_name). Measured 2026-08-13 across every REST-backed tool: all of them carried a configured credential, in one of the two ways. Measured 2026-08-14 after the fix, over the same set and both failure kinds: none does. An address is now printed without its userinfo and without its query, or as(unparseable)where that cannot be proven, and a served status is reported in this client's own words rather than the responding server's. The two channels therefore differ on purpose: the answer is redacted, the log is not, and an operator who treats them alike will be wrong about one of them. Two things happened toepics-doctoron 2026-08-14 and they are not the same event. Its write-gate block printed a fragment of a configured Olog password for one spelling ofEPICS_MCP_OLOG_URL, on every run with an armed gate rather than on a failure, because it rebuilt the address from the parse; that was a real disclosure and it is FIXED, by deleting the userinfo and withholding what cannot be proven. Separately, the command's own pattern-based redaction is gone rather than repaired: no unredacted exception was found that could reach it. Read that as the result of a search rather than a proof;docs/known-limits.mdentry 17 names the hole that search has. - Output redaction (ChannelFinder only). ChannelFinder owners and property values pass a
site-configurable allowlist. Olog entries come back WHOLE (title, text, author, attachments):
there is no read redaction on that plane, a deliberate prototype decision, see
docs/safety.mdfor the stated consequences.
The write gates are a guardrail on the sanctioned path, not a security boundary. This is stated plainly because the word "gate" invites a category error:
- A gate guards writes through this server. Anyone with a shell, or with the same EPICS client library this server depends on in order to run, can reach the same target without passing the gate. That path is outside the gate's reach by construction. It is the gate's shape, not a hole to be patched inside the server.
- The real boundary, where one is wanted, lives outside this process: network reach, account privileges, an external reconciliation watchdog. This server does not claim to be one, and no reader should mistake it for one.
- One of those boundaries is not hypothetical, and it applies to the sanctioned path too. Even
a write this server permits still has to satisfy the IOC's own access security, which decides
whether the value lands. The gate here is policy and audit over whether the server ATTEMPTS the
write; nothing in it reads or models what an IOC allows, so an allowlisted PV name is a statement
about our configuration, never a claim about the record. The
epics://guideresource states what is and is not measured about how such a refusal arrives. - The audit's promise is therefore every gate verdict, and every write through this server that reaches the I/O. It is not every write.
If you are deciding whether to deploy this in a facility, that distinction is the one to carry into the review. The full contract every in-server write gate must satisfy, including the deny paths and their evidence, is in docs/write-gate-contract.md.
The EPICS client is p4p, distributed as prebuilt wheels that
bundle the EPICS Base libraries, so no separate EPICS Base build takes part. REST access uses
requests. Secret scanning and push protection are enabled on this repository. Dependabot is
configured, and only half of it works: the GitHub Actions half updates, while the Python half
fails on every run, because the display engine is pinned as a git dependency on a private
repository Dependabot cannot reach. No Python dependency here is watched for a published
advisory. The measurement, the repair that was rejected and the manual route that replaces it
are in docs/known-limits.md, under Dependabot.
A release is the one action here that cannot be undone: a package index never lets a version number be reused. Four repository settings stand in front of it, measured on 2026-08-14 rather than intended:
- No API token exists. The upload uses Trusted Publishing (OIDC), bound to this repository, the
publish.ymlworkflow and thepypienvironment, so there is no credential to steal from here. - Only an administrator can create a
v*tag (rulesetrelease-tags, rulescreation,update,deletion,non_fast_forward). - The upload waits for an approval on the
pypienvironment, whose deployment policy admits tag refs matchingv*only. A job referencing an environment does not start until its rules pass, so no OIDC token is minted before that approval. - Third-party actions are allowlisted. Only GitHub-owned actions plus
astral-sh/setup-uvandpypa/gh-action-pypi-publishmay run.
Two limits, because the list above reads stronger than it is. prevent_self_review is false, so the
account that pushes the tag approves its own deployment: this is a stop against the accident, not a
second independent factor. And repository administrators may bypass the approval entirely, which is
GitHub's default and is not settable through its REST API.
main carries a ruleset that blocks force pushes and deletion. It deliberately does not require
pull requests or status checks, so a red commit can still reach main; the release workflow
therefore runs the full lint and test chain itself rather than trusting a separate CI run.