Skip to content

v0.6.0

Choose a tag to compare

@github-actions github-actions released this 14 Aug 03:02
· 222 commits to main since this release

Added

  • epics-pv://health answers the posture questions an approver asks after the write gates.
    Four additions, every one a boolean or a count and none of them an address: rest_tls (whether
    the REST planes verify certificates, whether a CA bundle is configured, and whether any plane
    speaks https at all, since verification is on by default and says nothing where there is no
    certificate to verify),
    rest_read_rate_limit (the opt-in REST GET throttle), allowed_roots_set (whether the opt-in
    file boundary holds a root) and channelfinder_redaction (how many ChannelFinder owner accounts
    and property names the redaction discloses, and whether each allowlist came from the site or from
    the built-in default). Each is named for what it MEASURES rather than for the question that
    brings a reader to it, and three of them needed that. rest_tls.verification_enabled resolves
    the precedence instead of mirroring EPICS_MCP_TLS_VERIFY, because EPICS_MCP_CA_BUNDLE wins
    over that switch: a server with the switch off and a bundle set does verify, and a field
    mirroring the switch would have reported it as unverified. The throttle carries its rest_
    prefix because a p4p PV read runs past it, so "reads are limited" without the prefix would be an
    all-clear for the reads that load an IOC. And the redaction counters say DISCLOSED, because an
    allowlist is the set of what passes through: zero is the most private posture, not a broken one.
    allowed_roots_set is decided by the same predicate the boundary itself asks, so a value of ;
    or of blanks, which resolves to no root at all, reports false rather than claiming a boundary
    that no file argument is held to. Deliberately absent, because a client keeps this payload: the
    CA-bundle path, the roots, and the allowlist entries. epics-doctor prints the allowlist
    entries; the bundle path and the roots are printed by no surface at all and stay in the
    environment the server was started with.

  • epics-testpv, a seventh command: a test PV without a control system. It serves
    TEST:Temperature, an analogue reading with a unit, and TEST:Heater, a writable switch, over
    PVAccess until Ctrl-C. The quick start previously began with softIocPVA, which ships with EPICS
    Base and which no page told you how to obtain, so the promise of a working PV without a facility
    was not keepable. This one needs nothing beyond the install, since p4p is already a dependency.
    It binds loopback unless --interface says otherwise, because a PVA server is a network
    service and its switch accepts writes, and it prints the port it actually bound, which is not the
    default one when that is already taken.

  • epics-init --out PATH writes the configuration file itself, with --force to replace an
    existing one. A shell redirect cannot promise an encoding, and this block is JSON a client has to
    parse: in Windows PowerShell 5.1, > writes UTF-16 with a byte-order mark and
    Set-Content -Encoding utf8 writes UTF-8 with one, and a strict parser rejects both. --out
    writes UTF-8 with LF on every platform. It refuses an existing file by default, because a client
    configuration usually holds other servers, and it writes nothing at all while placeholders remain,
    so filling them in and running again still works.

  • epics-init --absolute-command puts the resolved path of the installed server into the block
    instead of a bare command name. A client launched from a desktop icon does not inherit your
    shell's PATH, which is the commonest reason a correct-looking configuration reports only that
    the server did not start. An unresolvable command is now an error rather than a silent fallback.

  • epics-init warns when the configuration it emits arms a write gate. The check that follows
    prints the resulting posture (see the entry below) but does not evaluate whether such a server
    would START, and with --no-check no check runs at all. The warning also no longer claims that
    the loopback-only search reach is a start condition of both gates: it is one of the PV gate
    alone, while the durable audit path is required by both. It only points at "the check below"
    when one actually follows: the warning is deliberately emitted before the two branches that
    return without running a check (--no-check, and a block with placeholders left in it), which
    are the two cases it exists for, and on both of them it used to promise output that never came.

  • epics-doctor prints what each write gate would allow, and where a write can go. A new
    Write gates block, in the human report and as a write_safety key in --json, covering both
    the PV gate and the Olog logbook gate. A gate that is OFF gets one line saying so; an ARMED one
    adds its allowlist (a PV name pattern, or a set of logbook names), its rate limit, and where a
    write would actually go, which is the EPICS search reach for the PV gate and the target URL for
    the logbook gate. The audit log is named either way, with whether it can be appended to, cannot,
    or could not be decided without creating the file. --json always carries every field.
    Informative: it changes neither the verdict nor the exit code. It reports the
    environment of the command you ran, not necessarily that of a running server, and the heading
    says so. Four states are spelled out rather than left to the reader, because the obvious reading
    of each is its opposite: an empty PV pattern on an armed gate makes the server refuse to start,
    an empty logbook allowlist denies every write, an allowlisted remote target reaches a real
    logbook rather than a sandbox, and an audit verdict that could not be decided is not a "no". The
    audit check opens an append handle and writes nothing; where a file does not exist yet it says
    the answer cannot be determined rather than guessing. A PV pattern that does not COMPILE is named
    as such instead of being shown like a working allowlist, since that too refuses the start; and
    the line about a pattern's width says what was CHECKED, a comparison against a fixed list of
    allow-everything spellings, rather than calling the pattern narrow, because a pattern can admit
    every name and still be written outside that list.

  • Every validate_pvs file-mode answer now names the file it is about. The file_path echo used
    to appear on the empty-result answer only, so one mode came back with two different key sets and a
    client reading both had no stable key to match a result to its call. It now travels with
    shown_by_display and shown_by_display_capped as one group of file-mode fields, present together
    on both file-mode answers or on neither. Passing a NON-EMPTY pv_names still drops all three, and
    that is the point rather than an omission: the list wins, no file is opened, and echoing a path
    there would say the answer came from a file that was never read. An EMPTY list does not win, so
    the file is read as usual and the fields come back. The value is the argument as passed, not the
    resolved path, so it matches what the caller sent; with a symlink the counts describe the target
    while the field names the link. Purely additive: no existing field changes meaning, and the only
    visible difference on the empty-result answer is where the key sits in the object.

  • validate_pvs reports the glob cap, so the absence of a lower-bound note stops meaning
    "complete".
    The display walk has two ways of running out of budget, and this tool only ever
    named one of them. Beside the per-display context cap there is a glob cap: a <file> reference
    that still carries a macro is resolved by globbing the known displays, and past the cap the
    surplus matches are dropped, which removes whole embedded SCREENS rather than instances. It can
    therefore shrink either view while both capped verdicts stay false, and a caller reading the
    quiet answer as complete was reading it wrong. A separate notes entry now names it, worded as a
    statement about the walked dataset rather than about the queried file, because the engine records
    the source display of a capped glob and this tool does not turn that into a per-file verdict.
    coverage_audit and crossplane_check have reported the same signal since they shipped; this
    brings the third display tool in line and adds the regression test coverage_audit never had.
    How much this is worth is measured rather than assumed: on a 2878-display dataset the diagnostic
    holds 16 distinct source/target pairs across 4 source displays, and lifting the cap grows 7
    display views (the largest by 651 channels). On four datasets between 13 and 485 displays it
    never fires at all. Those same figures are why the note names no file: only 3 of the 7 growing
    views appear among the reported sources, so a per-file flag built on that field would miss the
    majority of the damage while looking precise.
    All three notes also stop calling the dropped references "template" ones. Measured against the
    engine, the diagnostic is fed by every glob-resolved <file> reference EXCEPT the template
    ones (embedded, navtabs, open_display and the rule edges), which is the opposite of what the
    wording said; the sentence has been wrong in coverage_audit and crossplane_check since they
    shipped and is corrected in all three.

  • Python 3.14 joins the supported set, and CI tests it. The trove classifiers, which ship
    in both the wheel and the sdist, now advertise 3.12, 3.13 and 3.14, and the CI matrix runs the
    suite on all three rather than on the two it used to. requires-python is unchanged at
    >=3.12, so nothing an installer resolves moves; what changes is that the top of the
    advertised range is now tested rather than merely permitted, which is the gap a classifier
    cannot express on its own.

Fixed

  • A caller could write fabricated records into the audit trail. Both write gates build their
    audit records out of values a caller chooses (a PV name; logbook and level names), and the trail
    is one record per line, so a newline in such a value ended the record and started the next one.
    Measured on a server with the PV write gate OFF, i.e. a caller permitted to write nothing at all:
    a set_pv_value call that was refused before any network access left three lines in the file, the
    middle one a complete, timestamp-bearing event=ALLOW record naming a PV nobody wrote. After that
    an ALLOW line no longer implies a write happened, which is what the trail exists to say. Every
    record is now escaped as it is FORMATTED, in the audit handler itself, so it also covers any
    field added later and any caller that never heard of the rule. The escape covers every
    character some reader treats as the end of a line, which is wider than it first looks: C0 and
    DEL for a byte-oriented reader such as grep, plus C1 (which contains U+0085 NEL) and U+2028
    and U+2029 for str.splitlines and any Unicode-aware log reader. No legitimate record
    changes
    : a real record's fields are identifiers, error codes and repr-formatted scalars,
    and the non-ASCII characters one does carry (units, accented names) all sit above U+009F. A
    record that DID carry a separator now shows it as rather than breaking the line, so
    nothing a caller sent is lost.

  • epics-testpv's writable switch stopped accepting writes after the first garbage collection.
    TEST:Heater is documented as a writable switch, and docs/mcp-clients.md shows a write-enabled
    configuration whose allowlist is ^TEST:.* so that a documented write has a target. The command
    passed its PV provider to the p4p server as an anonymous temporary, and p4p keeps no Python
    reference to one, so the first cyclic collection deallocated the shared PVs and removed the write
    handler from a server that kept serving. Reads were unaffected, which is why nothing looked
    broken. The refusal also named the wrong operation: the pinned pvxs answers an unhandled write
    with RPC not implemented by this PV. Writes to TEST:Heater now work for the life of the
    process. ⚠️ No published version was affected: the command itself is new in this release
    (see Added), so this repairs it before it ships rather than after. It is recorded because it
    is what a user would have met, and because it is the cause of four red CI runs on this
    repository that had been read as a flaky test.

  • An Olog write refused by the URL boundary no longer hands the caller the configured
    EPICS_MCP_OLOG_URL.
    That field accepts https://user:password@host/Olog, and the refusal
    reaches a caller verbatim, so a credential written into it was disclosed on the gate's most
    ordinary path: "remote and not allowlisted" is the boundary's normal state, and no service has to
    be unreachable for the message to be produced. Measured before the fix, all four write tools
    (create_log_entry, reply_to_log, add_log_attachment, update_log_entry) answered with the
    password in clear text. Wire change: the message now names the variable instead of its value,
    points at olog_write.target_allowed in epics-pv://health for the gate's own verdict, and ends
    with an instruction not to route around the refusal; a client matching on the old text must be
    updated. The configured address is disclosed to a caller on no surface, which is the posture
    epics-pv://health and epics-pv://config already held for this field; an operator reads it from
    epics-doctor's Write gates block, which needs that command's own environment to arm the gate.
    The logbook-allowlist refusal still names the logbooks it refused, which cannot carry a credential.
    ⚠️ This closes the refusal, and the other route out for the same value is closed separately in
    this release.
    A credential in EPICS_MCP_OLOG_URL also travelled with an ordinary HTTP failure
    of a PERMITTED target, including a loopback sandbox URL spelled with a userinfo; that is the
    shared REST layer, and the entry BELOW, "A credential in a service URL reached the client
    through error text", closes it. The server LOG stays unredacted by decision.

  • epics-doctor's final verdict named one problem and hid the others. The line an operator
    reads last named only the highest-ranking of the three honest-but-not-healthy categories
    (inconclusive identity probes, degraded planes, unverified planes) and said nothing about the
    rest, so someone who fixed what it named then discovered the next one: two problems read as
    almost finished. Measured over every state the tool can report, eleven of sixteen printed a
    verdict that hid at least one plane NAME. Every category present is now named, with its planes,
    and the earlier (N other plane(s) also unverified) clause is gone: it covered one of the two
    categories it outranked and gave a count where the rest of the report gives names. The PROBLEM
    verdict, which had named no plane at all, now names the planes that FAILED before it names the
    others, so the last line does not put the harmless ones first. Exit codes, --json fields and
    the per-plane block are unchanged; this is the verdict line only.

  • A password in a service URL was handed to the client in the clear. epics-pv://config
    printed channelfinder_url, archiver_url and alarm_url exactly as configured, and those
    fields are unvalidated strings: https://user:password@host/path is an ordinary spelling for
    them, so a deployment that used it published its password into whatever transcript the client
    keeps. A userinfo is now removed from each of them. Everything else is kept CHARACTER FOR
    CHARACTER, because the documented use of this resource is to compare the running server's
    configuration with the block in a client's configuration file, and the redaction that already
    existed for the logbook URL cannot serve that: it rebuilds the address from the parse, which
    lower-cases the host, drops a query and a fragment and percent-encodes a space. The boundary is
    urllib3's, the parser requests connects through, so a password containing @ loses its whole
    tail rather than only the part before the first one. An address whose userinfo cannot be removed
    provably is null instead of printed: a URL the parser refuses, one with no scheme or no host,
    a spelling in which the @ is not a userinfo at all, one where an @ survives the removal, and
    a cut whose result no longer names the same address. "(disabled)" still means the plane is not
    configured, and the two are different answers. ⚠️ A token in a QUERY STRING is not removed; that
    is the price of the character-for-character promise, and an error message drops the query for
    exactly the opposite reason (it names an address rather than being compared against one).
    ⚠️ This covers the resource, not every route out of the process; the error route was a second one
    and is closed in the entry below, while the server LOG is deliberately not. Credentials belong in
    the
    EPICS_MCP_*_AUTH header variables of the four planes that have one (ChannelFinder, Archiver,
    Alarm, Olog), never in a URL.

  • A credential in a service URL reached the client through error text, and through note fields
    of payloads that were returned SUCCESSFULLY.
    Measured 2026-08-13 over every REST-backed tool:
    each one disclosed a userinfo configured into an EPICS_MCP_*_URL, most in the error envelope,
    and four in a payload a client keeps (diagnose_connection, lookup_device_name,
    list_log_levels, and search_logbook with a level filter). A server answering 401 disclosed
    it twice per message, because requests keeps the userinfo in the prepared URL that its own
    error text quotes. Wire change, three shapes: an address is now printed without its userinfo
    and without its query, or as (unparseable) where that cannot be proven; a served status reads
    HTTP <code> <phrase> from this client's own table instead of the responding server's words; and
    the three Olog listing labels and the two ChannelFinder ones name their ROUTE (GET /levels)
    rather than a full URL. A client matching on the old text must be updated. ⚠️ The transport cause
    is passed through unchanged, deliberately: it is the only place "connection refused", "name not
    resolved", "timed out" and a TLS failure are distinguishable, and it carries no userinfo.
    ⚠️ Two routes are unchanged and one of them has no remedy: the server log is deliberately
    unredacted, and the Naming plane has no EPICS_MCP_*_AUTH variable, so a credential it needs can
    only live in its URL. epics-doctor's own pattern-based redaction is tracked separately.

  • epics-doctor printed a rebuilt Olog address next to a verdict about the configured one. The
    Write gates block prints the write target with its userinfo, query and fragment removed, which
    also lower-cases the host and percent-encodes a space, while the verdict beside it comes from the
    gate's own comparison, and that one reads EPICS_MCP_OLOG_URL exactly and case-sensitively.
    Measured: with the host configured in mixed case the report prints, character for character, the
    string already in EPICS_MCP_OLOG_WRITE_URL_ALLOWLIST and still says the target is not
    permitted, so an operator repairing the allowlist from that line ends up comparing two values
    that read identically and stays denied. Both target verdicts that can rest on the allowlist, the
    refusal and REMOTE and allowlisted, now say that the line is shown for reading and that the
    gate works from EPICS_MCP_OLOG_URL exactly as configured. The allowlisted one is not
    decoration: an operator tidying a working allowlist to match the printed address turns the gate
    into a deny-all, since the gate keeps comparing the mixed-case original. The note deliberately
    claims neither a repair nor that a comparison took place, and the second half of that had to be
    learned: seven states reach the refusal without the allowlist deciding anything, five of them
    vetoed as unparseable, one short-circuited by an unset EPICS_MCP_OLOG_WRITE_ALLOW_REMOTE and
    one denied by the https rule after the comparison had SUCCEEDED. The loopback verdict gets no
    note, because being loopback is a property of the address itself. The printed address is
    unchanged, and so are the exit code, --json and epics-pv://health: this is two lines of the
    human report.

  • Editing a logbook entry you had just read destroyed it, and nothing said so. A read gives a
    body back in two shapes, the raw source its author wrote and the description the server
    rendered from it, and nothing marked which of the two update_log_entry wants. Since that
    tool's description REPLACES the whole body, the obvious sequence, read the entry, add a line,
    write it back, replaced the raw body with its own rendering, and whatever the rendering had
    dropped was gone for good: this server cannot reach the archived version. All three tool
    descriptions now name source as the field a round trip reads. update_log_entry additionally
    returns a warnings entry when the new body starts with the entry's own rendering and NOT with
    its source, which covers writing the read value straight back and appending to it. A body
    rewritten in the middle, or prepended to, is the same mistake and is not detectable that way, so
    the warning is a safety net rather than a gate; and it says nothing about how MUCH was lost,
    since the renderer rewrites plain text too.

  • A configured value could still forge a line of the epics-doctor report, outside the write
    block.
    Control characters were escaped in the write block and nowhere else, while the report
    builds four more lines from values it did not author: the two ChannelFinder redaction allowlists
    and a plane's detail, which carries the raw EPICS search-path values. A newline in one of them
    put a complete second Write gates block, reading PV write: OFF, above the real one reading
    ARMED, together with a second Overall: line; a raw escape byte reached the terminal, where a
    conceal sequence hides everything printed after it. Every line the report builds from a
    configured value is escaped now. Long values keep their full text where they are instructions: a
    remedy is no longer at risk of being cut in the name of this.

  • The shipped operator guide listed four of the Olog gate's six checks. The list had already
    been corrected once, from "four" to "SIX", with a sentence saying the two easily-missed ones
    "are listed here"; the two were not added, so the sentence announcing the correction sat above
    the incomplete list it was correcting. The non-empty-target-logbooks check and the attachment
    size cap are bullets of their own now, in the order the gate applies them.

  • An ABSOLUTE audit path was reported as relative. The Write gates block warned that the
    server would resolve the path against a different working directory, for paths whose meaning does
    not depend on one. The test was whether the resolved string differs, and os.path.abspath also
    normalises, so an absolute path written with forward slashes, or a POSIX one containing a /./
    segment, took the warning. It now asks whether the path is absolute; a merely respelled one is
    named as the same file.

  • A broken EPICS_MCP_AUDIT_LOG_FILE could kill a write-enabled server with a bare
    traceback.
    Both write gates promise that an unusable audit path fails as a named
    configuration error, and two inputs escaped that promise because the clause caught only
    OSError: a NUL byte in the path raises ValueError, and a non-string raises TypeError.
    Both now produce the named refusal. Not reachable through the environment, since an
    environment value cannot carry a NUL, and reachable through a configuration built in
    process.

  • The documented epics-doctor --probe-pv example could only ever fail. It named
    SIM:PS-01:Cur-RB, a synthetic placeholder that looks exactly like a real PV name, so following
    the deployment guide produced disconnected, exit 1, and a remedy pointing at the IOC of a PV
    that never existed.

  • The ChannelFinder privacy example switched the default off while appearing to extend it. It
    showed EPICS_MCP_CHANNELFINDER_SAFE_OWNER_ACCOUNTS=recsync,ioc-svc; the built-in default is
    recceiver, and a value REPLACES the default rather than adding to it, so copying that line
    redacted every real owner. The example now repeats the default beside the addition and the
    replace-not-merge semantics are stated.

  • The quick start's second step told you to run the server by hand, which is the one thing its
    own --help says not to do: started that way it waits silently for JSON-RPC, which reads as a
    hang, and nothing consumed it, because the client starts the server itself.

  • The quick start described the wrong output for epics-diagnose. It promised "four lines
    beginning PV: and ending in connected, value=21.5", and neither half holds: a connected PV
    always gets a next-step line as well, so the report cannot stop at the live line, and where the
    PV reports an alarm severity that line carries it after the value. Measured against a real PV,
    the connected case prints seven lines. The page now describes the shape of the report rather than
    counting its lines, including the per-plane line every consulted service adds, so the next reader
    compares the right thing.

  • Two surfaces overstated what a shell redirect breaks. The quick start and the setup prompt
    said it produces "bytes no JSON parser accepts". Measured on the three files in question:
    Python's json.loads reads all of them from raw bytes, because detect_encoding recognises the
    UTF-16 and UTF-8 byte-order marks; Node and Python in text mode reject the two that carry one.
    Both surfaces now say what the four other places describing this already said, that a STRICT
    JSON parser rejects them, and they name the shell it applies to: Windows PowerShell 5.1, not
    Windows. Measured on the same machine, a redirect in PowerShell 7 and in cmd.exe writes
    BOM-free UTF-8 that every parser reads. The remedy is unchanged: let --out write the file.

  • The refusal on an install without the display engine named three of the five commands that
    still work.
    epics-crossplane and epics-coverage are the only two that need the
    opi_navigation engine; asked to run without it they refuse and tell the reader what else is
    available. That sentence still said "the other three commands" and omitted epics-init and
    epics-testpv, both added after it was written, so a reader on a published install was told
    that two commands they can actually run are unavailable. docs/tools.md carried a different
    incomplete set, omitting epics-mcp. Both now name all five.

  • Writing a switch by its LABEL is verified again: landed and not-landed no longer give the same
    answer.
    set_pv_value reads every write back, but on an enum PV the value read back is the
    numeric index while the label rides in a separate block, so a written label such as On matched
    neither comparison: a landed and a not-landed write both came back verified: null with a
    READBACK_UNVERIFIED audit line, which is one answer for opposite facts. A written label is now
    resolved against the record's own choices, case-sensitively and first match wins, and compared by
    index: verified: true when it landed, verified: false when it did not. Writing the index
    instead was already correct and is unchanged, and readback still carries the index, exactly as
    get_pv_value reports it. This matters most on a command or reset record, which declares no drive
    limits: the pre-write bounds check does not cover it either, so the readback was its only value
    safety net. One consequence to expect there: such a record often clears itself after the pulse,
    and if it has already cleared when the readback arrives it reads back its idle state, so the write
    is reported as a mismatch rather than as unverified. That is what the readback saw; judge the
    effect of such a command from the record's own status.

  • The display tools no longer miss what sits inside a tabbed widget. All four display-aware
    tools (validate_pvs, coverage_audit, crossplane_check, find_device) read the display tree
    through the shared navigation engine, and that engine walked a display's widgets flatly. A tabbed
    widget does not hang its content as a direct child: the display format nests it one level deeper,
    under the tab container. Everything inside a tab was therefore invisible, so navigation targets
    reached only from a tab were reported as unreferenced, and PVs that live only on a tab page were
    absent from the answer. Measured on a 97-display set, restoring the descent raises the edge count
    from 1493 to 1605 and the open_display edges from 92 to 204; across a larger corpus it recovers
    253 open_display actions and 36 navigation widgets spread over 23 files, one of them an operator
    entry point. Nothing warned about it, and that is the part worth knowing: a target made
    unreachable this way still counts as having no incoming link, and a display with no incoming link
    is seeded as an entry point, so the reachability ratio stayed at a clean 1.0 while the edges were
    missing. Expect a display set to report MORE references and MORE PVs than before, not fewer.
    Two further engine fixes ride along: a display whose glob-resolved reference matched its own file
    no longer loses its entry-point status over that guessed self-link, and the inventory walk itself
    got substantially faster on large sets. Server behaviour and every wire field are unchanged; only
    the completeness of the underlying analysis improves.

Changed

  • BREAKING: epics-init refuses --probe-pv together with --no-check. That call used to
    exit 0 and emit the block; it now exits 2 with a usage error, so a script passing both
    breaks. It was accepted in silence while --probe-pv was never read, because the run returns
    before the check that would have used it. --probe-pv is the option that turns "nothing is
    misconfigured" into "something actually works", and the quick start recommends it for that
    reason, so a user who passed both believed a PV had been probed when nothing had. Nothing else
    is lost: the emitted block is byte-identical with and without --probe-pv under --no-check.
    Drop one of the two options; without --no-check the PV is read as before.

  • BREAKING: epics-init --list refuses every option it used to swallow. --list --probe-pv NAME, --list --no-check, --list --set NAME=VALUE, --list --absolute-command and
    --list --out "" used to exit 0 and print the preset listing while the option they carried was
    never read, because --list returns before any of those values is used. All five now exit 2
    with a usage error, so a script passing one breaks. Nothing is taken away: the listing is built
    from the presets alone, so none of them could have changed it. The fifth is the one an earlier
    draft of this entry got wrong: --list --out PATH was already refused, but the older rule tested
    --out for TRUTH, so an empty value slipped past it and printed the listing. It keeps its own
    sentence, which says something the general one cannot, whenever --out is the only dead option
    in the call. The refusal is ONE rule that holds the parsed options against their defaults rather
    than a named rule per option, so an option added later is covered the day it is added, which is
    the gap this closes: these had accumulated one at a time. Calls that already exited 2 may say
    why differently. --list --force used to be answered with "add --out", a repair --list
    refuses as well; --list --no-check --probe-pv used to be answered as if those two were the
    problem; and --list --out PATH alongside another dead option now names all of them at once
    instead of sending you back for one more round per rule.

  • A failing archiver_retrieval plane no longer sends you to the variable that just passed.
    With EPICS_MCP_ARCHIVER_RETRIEVAL_URL empty the plane probes the MGMT URL, which is right for a
    single-JVM appliance, but a finding then opened with EPICS_MCP_ARCHIVER_URL and the remedy
    promises that the variable to edit is the one named at the start. On a split deployment that is
    the variable which had just been reported healthy on the line above, so following the advice
    broke the working half and left the broken half broken, while the setting that actually helps was
    never named. The finding now opens with the empty retrieval variable and says that the plane fell
    back; the MGMT variable is still named, because that URL really was the one probed. It says so
    only where the host ANSWERED and just the webapp is in doubt: when nothing answered at all, the
    MGMT plane has failed on the line above too and its address is what needs repairing, so that
    finding reads as before. Only the fallback case changes, and only its detail text: a plane with
    its own retrieval URL is untouched. --json consumers matching the old opening words of this one
    finding will not find them.

  • The api_error remedy no longer names one webapp as the right one for every plane. It ended
    "for an Archiver Appliance the mgmt port and not retrieval", and the remedy table is keyed by
    status and read by every plane, so on archiver_retrieval it recommended the endpoint the probe
    had just failed against, which is the signature of a split deployment. It now states which
    question to ask (which webapp does this plane read, and from which variable) instead of answering
    it for one plane. Affects the detail text of every api_error finding.

  • epics-pv://health now says whether the server may write, and which planes it has. It
    described the PV write gate only, so a server whose LOGBOOK gate was armed, with a service
    account and an allowlist behind it, reported write_enabled: false and nothing anywhere
    contradicted it. New fields: any_write_gate_armed (the whole write answer in one field, because
    deriving it from write_enabled is the mistake this fixes), an olog_write block with that
    gate's allowlist, rate limit and target predicates,
    write_pattern_is_a_known_allow_all_spelling (named for its METHOD, since true means
    certainly wide while false does NOT mean narrow: it compares the pattern against a closed
    set of spellings rather than reading the expression), naming_enabled and archiver_retrieval_enabled (the payload named four of the seven planes the
    doctor probes), and a pv_search block saying whether PV searches broadcast into the local
    subnets. Deliberately absent, because a client keeps this payload: the Olog URL, the audit path,
    and the raw address lists. Those stay with epics-doctor.
    ⚠️ Breaking, in one field each: write_pattern (health) and pv_write_pattern (config) are
    now null when no pattern is set, where they used to be the string "(none)". That string
    claimed a state the server refuses to start in, since an armed gate with an empty allowlist
    raises at construction, and nothing distinguished it from a pattern whose text is that word.

  • The deployment guide answers the questions that come AFTER it starts. Its troubleshooting
    section was entirely bring-up; it now also covers an instance that runs and may be pointed at the
    wrong facility, how to stop one (you do not, directly: an stdio server belongs to the client that
    launched it), what a runtime write refusal says, and the honest answer to "what has it read out
    of my facility", which is that no read is logged anywhere on this side. A new section covers
    uninstall and downgrade, including the four things an uninstall leaves behind: the block in your
    client configuration, the audit log on the path you chose, the framework's update-check cache,
    and any logbook entry a sanctioned Olog write created. And there is a plain statement about
    service versions, with the reasoning for not publishing a tested-versions table: what the server
    expects is documented, and epics-doctor plus get_appliance_info measure what YOUR services
    actually are.

  • set_pv_value no longer reads as the authority over whether a write lands. Its description
    said "the load-bearing, client-independent guard" and closed by calling the safety layer "what
    actually gates the write". Both are now scoped to what this server decides, which is whether it
    ATTEMPTS the put; the IOC's own access security decides whether the value lands, and this server
    neither reads nor models it. The description points at epics-pv://guide for the detail rather
    than growing, which is this repository's own convention for that. Nothing about the gates or the
    wire changed. ⚠️ What a refusal at the IOC looks like from here is stated in the guide as not
    measured
    : no live test in this project has an IOC decline a write. What is measured, and what
    the guide sends you to instead, is the always-on readback: where the server can read the value
    back and compare it, a value that did not land comes back verified=false. Where it cannot,
    verified is null with a note saying why, which is the answer on three measured paths (the
    readback pv_get itself failed, the readback carried no live reading, or the written value is
    not comparable with what came back). false is a measurement and null is the absence of one;
    neither is a statement about the IOC's reason.

  • The alarm reply says which of its two levels answers "why". get_pv_info now states in its
    own description that alarm.status_text is the coarse pvData NT category of the alarm SOURCE
    (DEVICE, RECORD, DB and the like) while the fine CA STAT condition (HIHI, LOLO, UDF, SIMM) is
    plain text in alarm.message. Nothing about the payload changed; what changed is that an
    assistant reading a PV over its HIHI threshold no longer has to guess why it says
    status_text=RECORD, and no longer reports a threshold breach as a record fault. get_pv_value,
    get_pvs and monitor_pv point at the same paragraph, since their descriptions already delegate
    the alarm block to get_pv_info.

  • A refused PV write now says what NOT to do next. Both PVWriteDeniedError messages, the
    disabled gate and the allowlist miss, carry an instruction not to route around the refusal by
    writing a different PV or taking another route, and to report it to the operator on duty. The
    allowlist miss got it too, although only the gate was asked for: that message names a PV, and
    naming one PV is what invites trying its neighbour. The gate message still says which variable
    arms it, the escalation stands beside that remedy rather than replacing it.

  • Documentation reorganised around getting it running. The deployment guide gains a
    troubleshooting section (symptom first: the client says nothing, the tools do not appear, the
    saved file is rejected, a write-enabled block will not start), a layout of what an install puts on
    your machine versus what only exists in a checkout, and the fact that epics-doctor reads the
    environment of the process YOU start rather than a client configuration file. The MCP client page
    now says where that file lives per platform, that the command is a bare name something has to
    resolve, and that the client must be restarted afterwards, which no page previously mentioned.

  • docs/safety.md states what leaves your machine for every plane, not just for logbook reads,
    including one outbound call that is not ours: the MCP framework checks pypi.org for a newer
    version of itself when it prints its startup banner. Set FASTMCP_CHECK_FOR_UPDATES=off to stop
    it, which matters in a segmented network where it has nowhere to go.

  • validate_pvs accepts a .plt Data Browser trend, where it used to refuse one. The
    display-PV engine collects two kinds of file, .bob operator screens and .plt trends, and this
    server was pinned to a revision from before that second kind existed. While that pin stood, the
    refusal "the inventory reads .bob files only, so this call can only come back empty" was simply
    true. The pin has now moved, and with it the refusal would have become a malfunction wearing the
    clothes of a safety check: a rejection whose stated reason had stopped being the case. Passing a
    trend now returns its trace channels with their connectivity, exactly like a display.
    Nothing that worked before changes: a .bob behaves as it always did, and every other suffix
    is still refused up front with INVALID_INPUT, before the inventory walk, for the same reason as
    before. The refusal message now names both readable kinds instead of one, so a client that
    guessed wrong learns what else it could have passed.

    Which view finds a trend depends on how the trend is REACHED, and that is worth knowing before
    the call.
    A trend embedded in a screen through a databrowser widget has its traces attributed
    to that screen, so only the trend's own view="file" finds them here; a trend opened by an
    open_file button is a top level in its own right and answers under either view. A trend is not
    a screen and is not reported as one: the inventory carries the kind as its own field.

    Two consequences for the other display-aware tools, neither of them a change to those tools:
    coverage_audit and crossplane_check now see the trace PVs of trends under their
    displays_dir root, and find_device can return a button-opened trend among the screens that
    show a device.

  • The four display tools now call the inventory walk's context cap by ONE name, and two of them
    stop hiding its second limit.
    The same cap was named three ways across the four
    (per-display context cap, per-instance context cap, and the bare the context cap), and a
    fourth way turned up inside crossplane_check beside its own, so one service named it two ways.
    This breaks a reader rather than a computation: an assistant that has read a tool's context_cap
    description and then searches the notes for that wording did not find it, on the very tool it had
    just read. One of the four was also wrong. per-instance is what crossplane_check calls the
    INVENTORY, while the cap counts reachability contexts per FILE, so that note named the thing the
    cap shortens instead of the cap. Every note now reads hit the per-display context cap, the
    wording all four argument descriptions, both CLI help texts and the shipped operator guide
    already used. Separately, the context_cap descriptions of crossplane_check and
    coverage_audit now also name the GLOB cap: both tools emit a note about it, but neither
    description mentioned it, so the only two limits a caller could learn about from those two tools
    were one each. No behaviour change, no field changed, and the counts are untouched: this is the
    wording beside the numbers that were pinned together in the previous release entry. Wording is
    now pinned too, across every place a tool names the cap.

  • find_device no longer claims its screen list is complete, because it cannot know that. Three
    notes and the tool description said "the screen list is complete" beside a capped live read. What
    they meant is true and now says so: the LIVE cap does not shorten the screen list. What they
    claimed is not: the screen list comes from the same inventory walk as validate_pvs, that walk
    has two caps of its own, and this tool reads neither, so a screen dropped by the glob cap is
    missing with nothing saying so. Wording only, no behaviour change; the caps themselves are now
    reported, see the next entry.

  • find_device now reports the two caps of the inventory walk, like its three sibling display
    tools.
    Its screen list comes from the same macro-aware walk as validate_pvs,
    coverage_audit and crossplane_check, and that walk has a per-display context cap and a glob
    cap. find_device was the only one of the four that read neither, so a screen left out by
    either cap was simply absent from the answer. Two new notes entries name the count and state
    that the screen list is a lower bound. Both are statements about the run rather than a verdict
    on the query, because neither cap records the screen a device lookup returns, and the absence of
    a note means no cap fired on that run, never "complete".

  • validate_pvs no longer calls a file's PV list a lower bound when that list cannot grow.
    Under the default view="file", the notes entry warning that the macro expansion hit the
    per-display context cap now also requires that the file declares a macro-templated PV of its
    own. A PV carrying no macro resolves to the same channel under every binding and is already
    enumerated at every cap, so no larger budget can add anything through it and the file's answer is
    exact. On a 257-display dataset the note stops firing on four files and no file whose list can
    actually grow loses it. Note for anyone comparing against 0.5.0: that release said no file
    which carried the note would lose it. Those four do, and losing it is the point.
    Unchanged, and deliberately so: shown_by_display_capped, and the same note under
    view="display", carry the DISPLAY verdict, which gets no such test and stays the more cautious
    of the two. Read a true there as "cannot be ruled out" rather than "known to be incomplete".