Releases: epicDirk/EPICS-MCP
Release list
v0.7.0
Changed
-
BREAKING: the three MCP resources moved from
epics-pv://toepics://.
epics-pv://health,epics-pv://configandepics-pv://guideare nowepics://health,
epics://configandepics://guide. Are you affected? Search your client config, your
scripts and your prompts forepics-pv://. No hit: nothing to do, because a client that
discovers resources throughresources/listpicks the new names up by itself. A hit: change
the scheme there. There is no alias period, and an old URI fails loudly rather than answering
something stale. The rename itself changed no payload, but two of the three changed elsewhere
in this release:epics://healthnow reportsserverasepics-pvinstead ofepics-mcp,
and the guide grew.epics://configis unchanged. Wherever an entry describes what an EARLIER
version served, here or in the released sections below, it keeps writingepics-pv://: those
sentences record the address of the day, so your search will hit them and they are not work. -
BREAKING, but only if you set
EPICS_MCP_READ_RATE_LIMIT:epics-doctornow exits3
where it exited0. The limit is off by default, and a run that is denied nothing is
unchanged in every field. Are you affected? Check whether that variable is set, above0,
in the environment yourepics-doctorjob runs in. Setting it is necessary for this change and
not sufficient: the exit moves only on a run whose own reads the throttle actually refused, so
the variable can stand for months and nothing move. No 0.6.0 output warns you in advance,
either way. Until now a run whose own reads that limit refused could still
exit0; it cannot any more, because a refused read means part of the run was never measured.
After upgrading, the new»lines name each plane whose probe the throttle refused.
What to do: if a CI job asserts exit0and goes red, raise the limit, or set it to0to
switch the throttle off, rather than lowering the assertion. The red is the finding: the0it
replaces was printed for a run that had not looked. Exit codes are0clean,1failed,2
usage,3inconclusive;3is not new, it already covered a failed identity probe in 0.6.0. -
find_deviceno longer calls a Data Browser trend an operator screen. A.pltopened by a
button is operator-facing, so the reverse-lookup returns it, and until now it arrived
indistinguishable from a.bob: counted among the screens, described as one by the tool, and on
an empty answer the note read "No operator-facing screen references this device/query" although
trends had never been counted apart. Each match now carriesscreens[].node_kind("display"
or"trend"), and the report carriesdisplay_countandtrend_count, counted positively
rather than one subtracted from the other, so a later third kind cannot land silently in the
display figure. The rendered header splits the same way and a trend's own row says what it is;
the empty answer denies both kinds. Additive on the wire: nothing was removed or retyped, and
the field stays calledscreens, because renaming it would break every caller in order to say
what its members now say themselves. The three capnotesfollow: they qualified "a screen" and
"the screen list" where a trend can be affected too, and now say "a screen or trend" and "the
match list". The description growth lands on the display-gated lane only; the core lane's
tools/listis unchanged. -
epics-doctorno longer blames your services for a limit you set on it. With
EPICS_MCP_READ_RATE_LIMITset tight enough to refuse the doctor's own reads, the report said
four different untrue things about a stack that was up. A refused TRANSPORT probe was
unreachable(exit1), telling you to check a host and port nothing had contacted; a refused
IDENTITY beacon wasidentity_probe_failed, pointing at a sub-path and an auth wall for a
request never sent; the cross-plane block then reported the host as dead and offered a swapped
archiver URL pair; and one read below the budget of a full run the refusal landed on the
archiver's ingest sub-probe, leaving every plane✓, every list empty and exit0under this
tool's strongest confirmation. A plane this command never asked is nowthrottled(»), listed
inthrottled_planesand NOT ininconclusive_identity_planes, with a remedy naming the limit
instead of a URL. A run denied any read reportsreads_denied, closesverification_complete
and exits3, which is the only signal for the sub-probe case. Scripts reading the exit code
alone see a0become a3on a deployment whose configuration did not change. -
An unwritable audit path no longer hands its full local path to a tool caller, at EITHER write
gate. Both refuse withSAFETY_CONFIG_INVALIDnamingEPICS_MCP_AUDIT_LOG_FILEand withholding
its value; the path stays in the exception'sdetails, which the tool boundary does not send.
Each gate is built on first use unless its own write flag is on, so the refusal is a tool ANSWER
rather than a start-up failure: on the Olog side from the four write tools, and on the PV side
fromset_pv_value, which builds its gate BEFORE it checks whether writes are allowed, so this
reached callers even with PV writes off. Operators reading a start-up failure on stderr now see
the variable name instead of its value and read the value back from the environment of the
process they started. Which case arrives how:operator_guide.mdunderSAFETY_CONFIG_INVALID,
anddocs/safety.mdfor the posture and the measurement behind it. -
The server now names itself
epics-pv, the key a client registers it under, in both places it
answers "which server am I".serverInfoon the handshake and theserverfield of
epics-pv://healthboth saidepics-mcp, which is the DISTRIBUTION name: the package on PyPI
and the console script keep it, and neither changes. Nothing hangs behaviour on either value (the
MCP specification reservesserverInfofor display, logging and debugging), so this costs a
reader nothing except the wrong trail it used to lay when two of the three servers in one client
log named themselves after something else. -
find_devicenow says that every match comes back with the roles it uses the device in.
The answer has carriedscreens[].rolesall along and nothing on the surface said so, so
"which screens can WRITE this device?" was answerable and unadvertised. Nothing changed on the
wire beyond the sentence. -
get_pvsnow names the field the engineering unit rides in. Its description referred to
get_pv_infofor the metadata, so a caller asking whether channels labelled alike carry the same
unit had to take a second hop to learn that one batch call answers it.unitsandprecision
are now named where they sit, indisplay, per PV. Nothing changed on the wire beyond the
sentence. -
set_pv_valuenow says that an operator screen writes through its widget, anddocs/tools.md
that no preset arms a write gate. Both were facts a caller needed before the call and neither
was anywhere the server delivers: an assistant asked to change a value visible on a screen
reached for the gated tool and was refused, andsandbox, the one shape with nothing left to
fill in, reads like the one where writing is on. Nothing changed on the wire beyond the two
sentences, and this change leaves theinitializeinstructions untouched (a separate entry in
this release does edit them). -
The
diagnose_pvprompt now establishes WHICH world it read from, and corroborates a negative
before concluding. The chain started atget_pv_infoand ended at the monitor, which taught a
plan that could reach a confident answer without ever saying which plane had answered it. Step 1
now says to read thereachfield of that first answer before interpreting anything else, and a
new final step counter-checks withfind_channels,get_pv_historyandsearch_logbook,
because a PV that does not connect looks the same whether it does not exist, is down, or is
simply out of reach from here; a negative from one plane is not a finding until a second plane
agrees. Every tool the prompt names is registered in every install, deliberately: an earlier
draft named the display-gatedfind_device, which on a core-only install would have been an
instruction the client cannot follow. This isprompts/list, a surface a client shows without
any tool call.setup_epics_mcpgains the throttle marker for the read rate limit as a fourth
finding to read out of anepics-doctorreport. -
The server's
initializeinstructions name all six Olog write-gate conditions. The
enumeration in the handshake header listed five and left out the named target logbook, so a
reader counting the gate from the header got a narrower picture than the operator guide, which
said six all along. Nothing about the gate itself changed; what changed is that the two agree. -
diagnose_connection's naming plane runs the same probe aslookup_device_name. Both used
to carry their own copy of the three-step lookup (config gate, reachability first, then
validate), which is how two answers to one question start to disagree. The gatherer calls the
shared query now, like its three sibling planes already did. Semantics are unchanged, including
that an unreachable service is withheld rather than read as "not registered". On the wire,
evidence.naming.notereadsNaming lookup withheld: ...where it readNaming error: ....
The older wording survives on one path and deliberately so: the gatherer keeps a wideexcept
for the failures the shared query does not catch, and a note from there still reads
Naming error: .... -
**
epics-initnames the variables from your own shell that reac...
v0.6.0
Added
-
epics-pv://healthanswers 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
speakshttpsat 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) andchannelfinder_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_enabledresolves
the precedence instead of mirroringEPICS_MCP_TLS_VERIFY, becauseEPICS_MCP_CA_BUNDLEwins
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 itsrest_
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_setis 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-doctorprints 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, andTEST:Heater, a writable switch, over
PVAccess until Ctrl-C. The quick start previously began withsoftIocPVA, 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--interfacesays 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 PATHwrites the configuration file itself, with--forceto 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 utf8writes 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-commandputs 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'sPATH, 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-initwarns 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-checkno 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-doctorprints what each write gate would allow, and where a write can go. A new
Write gatesblock, in the human report and as awrite_safetykey 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.--jsonalways 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_pvsfile-mode answer now names the file it is about. Thefile_pathecho 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_displayandshown_by_display_cappedas one group of file-mode fields, present together
on both file-mode answers or on neither. Passing a NON-EMPTYpv_namesstill 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_pvsreports 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 bothcappedverdicts stayfalse, and a caller reading the
quiet answer as complete was reading it wrong. A separatenotesentry 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_auditandcrossplane_checkhave reported the same signal since they shipped; this
brings the third display tool in line and adds the regression testcoverage_auditnever 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 incoverage_auditandcrossplane_checksince 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-pythonis 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
au...
v0.5.0
Added
-
validate_pvscan answer the other question about a display:view="file" | "display". The
tool has always reported what a.bobfile itself declares, attributed across every display that
embeds it. That is the right answer for a fragment and the wrong one for a screen: a display that
only composes embedded fragments declares nothing of its own, so it answeredtotal: 0while
resolving thousands of channels.view="display"asks the second question, what the file
resolves to when opened as a display, fragments included. Measured on a 257-display dataset, the
two views disagree on 54 files, 42 of which answeredtotal: 0under the default, the largest
hiding 5846 channels. The default stays"file", so nothing changes for existing callers.
Both views come out of the same inventory walk, so the second one is free. -
Every
file_pathresult now carriesshown_by_displayandshown_by_display_capped, and
underview="file"anotesentry says how many channels the display view adds. The counts let
a caller see which of the two questions was answered without running the other one. The capped
flag is computed separately from the file view's own cap verdict, which asks a different question
(see theFixedentry below). -
epics-init, a sixth console command: the configuration you need, without reading the
reference first. It prints the MCP client-configuration block for one of four deployment shapes
(sandbox,ioc-only,ioc-archiver,full;--listdescribes them) and then runs
epics-doctoragainst exactly that block, so generating a configuration and checking it are one
step. The block goes to stdout and everything else to stderr, soepics-init --preset X > .mcp.jsonyields a usable file.--set NAME=VALUEfills or adds a variable,--probe-pv NAMEis
passed to the doctor,--no-checkemits only. It introduces no newEPICS_MCP_*variable: the
presets set existing ones. Exit codes are the doctor's own (0/1/3), with2for a usage
error. Three behaviours worth knowing: a value still carrying a placeholder makes it REFUSE the
check and name it rather than report an unactionable failure (it reads the value in any case,
<ARCHIVER-HOST>as well as<archiver-host>, and does not fire on a named regex group such as
a write-gate pattern); a shape with no REST plane probed without--probe-pvis reported as
confirming nothing, because the live plane makes no network call in that case; andsandbox
searches BOTH ways a PVA client can, UDP broadcast to127.0.0.1and TCP unicast to
127.0.0.1:5075, so it reaches a soft IOC running natively on the host as well as one in a
container, which typically publishes only its PVA TCP port and no UDP search port. -
setup_epics_mcp, a third MCP prompt. The same walkthrough conversationally: it asks about
each service plane in turn and ends by naming theepics-initcommand to run. -
monitor_pvsays whether the channel was reachable. A newconnectionfield
(connected/disconnected/unknown) travels with the events, andconnection_detail
adds one sentence whenever there is something to explain. Zero events used to be ambiguous
between "the PV was quiet" and "the PV was never there", andget_pv_valuecontradicted this
tool on the second case by raisingPV_TIMEOUT, so the two disagreed about one fact.
connectedis claimed only where a delivered value proves it; a stream the server ended, or
one that reported an error, isunknownrather than a guess. A subscriptionRemoteErrornow
reaches the caller as well, where before it only went to the log and left an unexplained empty
result. The state is subscribed for rather than probed separately, so it describes the same run
as the events. Purely additive: existing fields are unchanged.
Removed
- The Olog read redaction is gone: every logbook read returns the whole entry.
search_logbook,
get_log_entry, the create/reply/update echoes andlist_log_attachmentsnow carrytitle,
description,owner(the author),source,propertiesand the raw attachments list for every
server, anddownload_log_attachmenthands the bytes back (size-capped) without an opt-in. The
redaction had been built against an assumed privacy rule that was never specified for this server,
and it cost the logbook its point: a search returned ids whose content the caller could not judge.
A deliberate prototype decision (2026-08-01), consequences stated indocs/safety.md; if a real
facility privacy specification ever arrives, it will be rebuilt against that specification. With
it go, user-visibly: the env varsEPICS_MCP_OLOG_ASSUME_TEST_DATAand
EPICS_MCP_OLOG_ALLOW_ATTACHMENT_DOWNLOAD(now unknown, the config warns if still set), the
error codesOLOG_WHOLE_MODE_REQUIREDandOLOG_ATTACHMENT_DOWNLOAD_DENIED, thewithheld
fields of the download/list results, and the doctor'sOlog free-text:line together with the
olog_freetext_withheldfield its privacy report carried, whichepics-doctor --jsontherefore
no longer emits. The whole-mode preconditions ofadd_log_attachment/update_log_entryare
replaced by the write gate itself:
its env + URL checks now run BEFORE the pre-write read, so a target the gate refuses is never
even read. Both write gates, the ChannelFinder redaction and the withheld-is-not-no semantics of
the other planes are unchanged. - The alarm twin of that redaction is gone too.
is_alarm_configuredreturns the authored
fields (description/guidance/displays/commands/actionsand the serializedconfig_msg)
with their values, exactly the handling instruction the withholding used to blank, and
get_alarm_historyevents carryuser/host/command/config_msgagain. The known-field
allowlists stay as structure: an unknown field a future logger version adds is still dropped.
Changed
validate_pvsrefuses an unusablefile_pathat once instead of walking the whole dataset
first. A path that is not a.bob, or that lies outsidedisplays_dir, used to run the full
display-PV inventory, tens of seconds on a large dataset, and only then return an empty result
(or, in the second case, the error it could have given straight away). Neither input can produce
a PV: the inventory reads.bobfiles only and resolves embedded targets against that same
collected set, so the outcome was settled before the first file was opened. Both are now
INVALID_INPUT, and the message names the way out (pv_namesfor a plain list of PVs).
A client that passed a non-.bobpath today received a successfultotal: 0, and will now
get an error. The suffix comparison folds case, soUPPER.BOBis still a display; and a
genuine.bobthat declares no realca/pvachannels is unchanged, that stays an honest
total: 0, not a refusal. Passingpv_namesas well makes the list win, and the file path is
neither read nor refused.coverage_auditreports a missing alarm tree before doing the work, not after. Asking for
the alarm plane without naming a tree (alarm_config) was already anINVALID_INPUT, but the
refusal arrived only once the display-PV walk had finished, tens of seconds on a large dataset,
for a verdict the arguments alone decide. Same defect as thevalidate_pvsone above, in the
sibling tool. The error itself is unchanged.- The
compare_machine_stateprompt no longer suggestsvalidate_pvsfor a non-display file.
With areference_filesuch as a CSV or a JSON snapshot it now tells the client to read the file
itself, because naming the tool would hand over a call the server is now certain to refuse. A
.bobreference file (in any capitalisation) is unaffected and still uses the tool. - Five capped arguments now reject a non-positive value instead of answering emptily.
monitor_pv.max_events,find_channels.max_resultsand thecontext_capof
crossplane_check,coverage_auditandfind_devicerequire>= 1, andmonitor_pv.duration
requires> 0. A cap of0did not fail before: it succeeded and returned nothing, which a
client cannot tell from "the thing you asked about does not exist".find_channelswas the
sharpest case, returning an empty channel list together withcapped: true, so the answer
claimed there was more while showing none. A client passing0today will now get a
validation error rather than an empty result. The four sibling caps that already carried this
bound stated the reason at the call site; it now holds everywhere, watched by a test over the
live tool registry rather than a list, so a future tool is covered the day it is registered. - Ten
timeoutarguments now reject zero and below.get_pv_value,get_pvs,get_pv_info,
set_pv_value,discover_pvs,diagnose_connection,find_channels,is_archived,
validate_pvsandfind_devicerequire> 0, which the other nineteen timeouts already did.
A client passingtimeout=0today will now get a validation error naming the argument,
before any request exists. This was deferred once as a mere inconsistency, on the assumption
that a zero timeout fails honestly rather than fabricating an answer. Measured over all ten,
that assumption held for five and was wrong for the other five, which returned a
plausible-looking result instead:find_deviceanswered "No operator-facing screen references
this device",validate_pvsreported the PV as disconnected,diagnose_connectionnamed a
cause,discover_pvsandget_pvscame back empty. Two of the five that did raise pointed at
the wrong thing:PV_TIMEOUTblames the device rather than the argument, andis_archived
surfacedINTERNALwith a server-side traceback for what is a caller input error. The registry
guard that watches this now ...
v0.4.0
Added
-
Every
epics-doctorline that reports a problem now names its remedy. Three statuses already
did (ca_errornamed the CA bundle variable,api_errorthe mgmt/retrieval mix-up,config_error
the variable to set) and four did not:unreachableprinted only the transport error,
backend_downnamed the consequence rather than the fix, anddisconnectedand
identity_probe_failedsaid nothing about what to do. The remedies now live in one table keyed by
status, appended to the observation and never replacing it, so--jsonreaders get them in the same
detailfield. Anunreachableplane also names the variable it reads its URL from, which is the
one case where the reader cannot tell from the message which variable to look at.unverified
andno_ingestdeliberately stay without one: the first already carries the specific clue it
measured, and the second is a fault inside the appliance, not in this configuration. -
--versionon all five console commands (epics-mcphad it;epics-doctor,
epics-diagnose,epics-coverageandepics-crossplanedid not), from one shared helper, so the
version source and the command-name prefix cannot drift apart. Each prints its own name and the
package version. Note: on the two display-aware commands the engine check still runs before the
arguments are parsed, so on a core-only install those two report the missing engine instead; that
limit is pinned by a test rather than left to be rediscovered. -
epics-doctornotices an Archiver appliance that is not ingesting.getApplianceInfoproves
that an appliance is answering and nothing more: one whose engine has never spoken to its IOCs
answers it exactly like a healthy one, so the doctor printedokfor a deployment that was
archiving nothing. That is a wiring fault, the class this tool exists to catch. The identified
appliance is now also askedgetApplianceMetrics, and two things make it a finding: channels held
with none connected, or the appliance's ownstatusreporting a stopped webapp. The second is
the worse fault and is invisible in the counts, because when the engine webapp does not reply,
mgmt cannot merge its numbers and they vanish from the row whilepvCountsurvives, with the
payload still served as HTTP 200. -
New plane status
no_ingest(~), exit0. Deliberately not a failure: a freshly
commissioned or fully paused appliance is legitimately in this state, and failing the run would
makeepics-doctorcry wolf in every CI job that calls it. It is not silent either, and that is
what the next entry is for. -
New
--jsonfielddegraded_planes. The planes that proved their identity and are measurably
not doing their job. It exists because every other signal stays clean for such a plane:okand
verification_completeremaintrue,unverified_planesandinconclusive_identity_planes
remain empty, andidentified_planeseven lists it, since its identity IS proven. A script
written against the documented field list would otherwise read a non-archiving archiver as
positively confirmed. Purely additive; no existing field changes meaning and no exit code moves.
Changed
- The archiver plane now issues three requests on a healthy run rather than two, and the ingest
probe uses a single attempt with a 15 s floor instead of the shared retrying session. urllib3
applies the timeout per attempt, and this route fans out to three internal requests per cluster
member: measured against a 16-member cluster it answers in 7.3 s but takes 23.3 s to fail under
the default 3-retry policy, which would have made the check blind there while slowing every run. - The archiver identity probe goes through the same shared beacon fetcher as every other plane. It
was the one plane building its request inline, which already made that helper's "the one place
every identity probe issues its request" contract untrue. epics-doctor'sconfig_errorline now names the variable to set at its START, and the
remedy points at that position. Measured against the last published release: 0.3.0 ended the
observation with "Set EPICS_MCP_ARCHIVER_URL (the MGMT webapp URL)", the remedy table then took
that instruction over, and it now reads "Set the variable named at the start of this finding",
the constructionunreachablealready used. This matters beyond wording becausedetailis the
field--jsonreaders are told to use, so a consumer matching the older text will not find it.
The position is guarded; the wording deliberately is not.- BREAKING: the import package is now
epics_mcp(wasepics_pv_mcp).import epics_pv_mcp
stops working;import epics_mcpreplaces it, one for one, with no other change to the API. This
completes the rename begun in 0.3.0, where the distribution, the repository, the server command
and the server's MCP identity already becameepics-mcpand only the import package did not. It
is done now rather than later because every release under the old import name grows the set of
installations a rename breaks. - BREAKING: the
epics-pv-mcpconsole command is removed. Useepics-mcp, which has been the
primary command since 0.3.0. The alias was added in 0.3.0 for anyone following pre-rename docs,
and 0.3.0 shipped it: every installation of that release carriesepics-pv-mcp, so upgrading to
0.4.0 removes a command that works today. Check your wrapper scripts and MCP client configs for
it. Nothing older is affected, because 0.3.0 was this project's first published release of any
kind. The four diagnostic commands (epics-doctor,epics-diagnose,epics-crossplane,
epics-coverage) are unaffected. - BREAKING: the
allextra is removed.pip install epics-mcp[all]no longer resolves; use
epics-mcp[dev], which is whatallcontained. It was exactlyepics-mcp[dev], so it promised a
reader everything the package can do and delivered the developer toolchain, and the display-aware
tools it seemed to imply are not an extra at all but a local dependency group.devis now the
only extra. Nothing else changes: no dependency is added or dropped by this. - The product is called EPICS MCP. The prose name was still "EPICS PV MCP Server" in 19 places,
including the README H1 (which is the project page title on the index), theCITATION.cfftitle
that published work cites, and the first line of the operator guide that ships in the wheel. The
rename toepics-mcpin 0.3.0 was made because the PV plane is one of six, so a title saying PV
contradicted its own reason. Nothing user-facing behaves differently; the distribution, the
commands, the import package and the MCP identity are unchanged. epics-doctorexits 1, not 2, on an internal error. 2 is the usage-error code across these
commands and in argparse, so the old value told a wrapper the caller had passed something wrong
when the command itself had failed. A genuine usage error (an unknown flag) still exits 2.
Note the cost: exit 1 is also "a configured plane hard failed", so the exit code alone no longer
separates the two. They differ on the streams: an internal error writes adoctor:line to
stderr and no report to stdout.
Fixed
- The display-aware CLIs tell a broken engine apart from a missing one.
epics-crossplaneand
epics-coverageprobed for theopi_navigationengine with a name lookup only, so an engine
that was installed but did not import (typically a missing transitive dependency) passed the
check and the command then died with a bare traceback. It now reports the failure with the
underlying exception named and exits 1, while a genuinely absent engine keeps its explanation and
exit 2. The advice differs too: installing the engine will not fix a broken one. - The core server no longer dies over an OPTIONAL capability. The display-capability probe called
importlib.util.find_specbare, and it runs at module level, so an import hook that RAISES for
opi_navigationtook the wholeepics_mcp.serverimport down: exit 1 with a traceback, on a
server whose PV tools never needed the display engine at all. The probe now answers instead of
propagating. A module a finder reports as genuinely absent is the supported core-only state and
stays silent, as before; a finder that could not answer is a different claim and is logged loud
rather than wearing the "not installed" message. The sibling CLIs already behaved this way, so one
package was answering the same question two ways. - A long live value in the
find_devicereport is capped at 80 characters, not 82, as its own
documentation promised. - The ready-to-paste write-enabled MCP client block now starts. As shipped it omitted
EPICS_MCP_AUDIT_LOG_FILEand the loopback reach settings, both of which a write-enabled server
refuses to start without, so a reader who pasted it saw only "server not connected". - The setup instructions no longer describe a
.envfile. Four places told adopters to copy
.env.exampleto.env, or to runepics-doctorto confirm one. Nothing in the server ever
loads a dotenv file; configuration is read from the process environment, and a variable that does
not reach the process is dropped silently..env.exampleis a reference to copy lines OUT of. EPICS_MCP_ARCHIVER_RETRIEVAL_URLis spelled with its prefix in the README. The unprefixed
form does not bind, so a split-appliance operator who followed it got history requests sent to
the mgmt port with no error to explain it..env.examplenames all four tools the Olog write gate covers, not two. It was the only
place that understated the gate's reach.- Every link on the PyPI project page now goes somewhere. The README is the
long_description,
and on that page there is no repository around it, so its relative targets ...
v0.3.0
Added
New tools
list_channel_vocabularylists the ChannelFinder property keys and tag names that
find_channelscan be filtered on, as{enabled, properties, tags}(names only).properties
is reduced to the same safe-property allowlistfind_channelsenforces, so it never advertises
a key that would then be refused. An unreadable listing raises rather than reading as "there are
none". ReusesEPICS_MCP_CHANNELFINDER_URL; no new variable.get_appliance_inforeturns the Archiver Appliance's own topology (identity, the per-plane
root URLs,cluster_inet_port,version), with no PV argument. It answers "am I pointed at the
intended cluster before I trustlist_archived_pvsorget_pv_history", where the wrong cluster
silently yields a complete-looking list of the wrong PVs. A 404 means the wrong endpoint (the
retrieval webapp serves/retrieval/bpl, not/mgmt/bpl) and propagates as an error.list_log_levelsreturns the Olog level names plusdefault_level, ornulland a note when
the server does not state a default unambiguously. Call it before filtering a search by level: an
unknown level returns 0 hits rather than an error.update_log_entryedits an existing Olog entry'stitle, body,level,logbooksortags.
Whole-mode only. An omitted argument means unchanged: the tool round-trips the entry and
overlays only what was passed, so attachments, properties and unedited fields survive the
server's destructive full replace. The logbook allowlist is keyed on the union of current and
resulting logbooks, because moving an entry into a logbook and pulling it out of one are both
writes to that logbook. An entry whose attachments have duplicate or missing filenames is refused
rather than edited, since attachment retention is filename-keyed.add_log_attachmentattaches files to an existing Olog entry. Whole-mode only, and purely
additive for content. Note the server re-stampsownerto the write service account on every
call, because this endpoint is its destructive update.list_log_attachmentsanddownload_log_attachmentlist one entry's attachments and
fetch raw bytes by (log id + filename) or GridFS id. Bytes bypass the entry redaction, so a
download is withheld unless whole-mode andEPICS_MCP_OLOG_ALLOW_ATTACHMENT_DOWNLOADare both
in effect. Anoutput_pathmust be a new file: it refuses to overwrite and refuses a symlink
target.
New capabilities on existing tools
- Attachments on write.
create_log_entryandreply_to_logtakeattachments(workspace file
paths, sent as multipart) andembed_image_base64for a small inline image. Uploads ride the
existing Olog write gate plus a size cap (EPICS_MCP_OLOG_ATTACH_MAX_BYTES), which also bounds
the download body. - ChannelFinder query filters on
find_channels:has_properties,lacks_properties,
not_property_values,has_tagsandlacks_tags, pluscount_onlyfor an exact, window-free
match count from the/countendpoint. An unknown property name is a filter, not a silently
ignored parameter, so a typo narrows the result to roughly zero. Property filtering is gated to
the safe-property allowlist: filtering on a redacted property is refused, because it would
reconstruct the partition the response projection hides. levelandtitlefilters onsearch_logbook.titlematches whole words, not substrings,
and is a separate axis fromtext, which searches the body. A blank filter is refused before any
request, because the server's answers to a blank value are misleading and disagree between the two
fields. An empty level-filtered result carries a note when the value is not a configured level.- Archiver fields that were already fetched and then discarded are now surfaced, with no extra
HTTP request.is_archivedadds the getPVStatus connection-history cluster
(connection_loss_regain_count,connection_first_established,connection_last_restablished).
get_archive_infoadds the alarm, display and control limits plusunits,precision,
controlling_pv,policy_nameandmodification_time. The nine numeric limits are always
present and read"0.0"when the PV had no control info, so"0.0"does not necessarily mean a
literal zero limit. - Write-side
levelvalidation.create_log_entry,reply_to_logandupdate_log_entryrefuse
a level the server does not list, and refuse a blank one separately, since a blank level would
silently clear the entry's level. The check runs before the rate token, so a typo costs no token,
and only when alevelwas passed. entry_idin the FAILED write audit. The server archives and mutates before answering, so a
timeout can leave an applied write in front of a client that sees FAILED. The record now names the
entry.- Client-side consent hint on
set_pv_value. Itstools/listentry carries
_meta["anthropic/requiresUserInteraction"]=true. A client that honours it prompts a human on
every call and fails closed when it cannot ask. This is advisory defense-in-depth only: the
server-side write gate (env gate, regex allowlist, rate limit, audit) is unchanged and remains the
sole client-independent guard.
Diagnostics
epics-doctorchecks the alarm logger's Elasticsearch backend. The transport probe is a blind
HEAD and reported the plane healthy even when the backend was dead. The identity probe now reads
elastic.statusfrom the same response body it already fetches and reports the newbackend_down
status (exit1) when the logger says its backend is notConnected. This is distinct from
unverified: identity is proven, and the service reports its own backend broken.epics-doctorreports a failed identity probe asidentity_probe_failed(glyph!, exit3,
INCONCLUSIVE) instead of collapsing it intounverifiedand exit0. A 2xx that merely could not
be named staysunverifiedand exit0. New--jsonfieldinconclusive_identity_planes.
Migration: a script that gated on exit0now sees exit3for a reachable but
unidentifiable plane, and should readinconclusive_identity_planesalongsideunverified_planes.python -m epics_pv_mcp.find_moderate_pv, a read-only fixture finder that walks the Archiver
MGMT event-rate report, filters a rate band, and counter-verifies each examined candidate against
the target window with a real history fetch.
Changed
- Renamed: the distribution is
epics-mcp, the repository isepicDirk/EPICS-MCP(before
anything was ever published, so nothing breaks for anyone). The old name undersold the server:
the PV plane is one of six. The server command is nowepics-mcp(the oldepics-pv-mcp
stays as an alias), and the server identifies itself to MCP clients and to the Olog as
epics-mcp. The import package (epics_pv_mcp) and the fourepics-*diagnostic commands
keep their names for now; renaming those is a separate, deliberate step. - BREAKING: tool argument names unified. The tools carried four different argument names for
"the PV this tool is about". They are nowpv_namefor a single PV (monitor_pvfromname;
is_archived,get_pv_history,get_archive_info,is_alarm_configuredandget_alarm_history
frompv) andpv_namesfor a list (get_pvsfromnames,validate_pvsfrompvs).
Unchanged:lookup_device_name.name(a device name, not a PV), the glob parameterspattern,
name_patternandquery, and every output field. This affects INPUTS only; the output field
of the five renamed REST tools is stillpv, and aligning those would be a second breaking change
for anyone reading results. MCP clients that call these tools by argument name must follow;
positional calls are unaffected. The old name now returns a cleanToolError. - BREAKING: the server runtime moved from the SDK-bundled FastMCP 1.0 (
mcp.server.fastmcp) to
standalonefastmcp(fastmcp>=3,<4;mcp>=1,<2stays formcp.types.ToolAnnotations). This
puts both project MCP servers on one stack and removes the two-ToolError-class hazard by
construction, since only onefastmcpis on the path. Anyone embedding this server and catching
mcp.server.fastmcp.exceptions.ToolErrormust switch tofastmcp.exceptions.ToolError. - Typed output schemas. 22 of the 32 tools now advertise a typed
outputSchemainstead of an
opendict[str, object], so a caller can tell "no such PV" from "the service is not configured
and I could not look", and a complete result from a truncated one. Notable field contracts:find_channelsreturns disjoint fields per mode, and the configuration splits them again:
a configured list gives{enabled, channels, total, capped}, a configured count gives
{enabled, match_count}; unconfigured they are{enabled, channels, total, note}and
{enabled, match_count, note}.enabledis the only field present on every path.discover_pvsreturnsDiscoverPvsResult:pattern,pvsandtotalon every path, plus
cappedandsourceon the wildcard-with-ChannelFinder path andnoteon both wildcard paths.list_archived_pvs,get_appliance_info,get_archive_info,list_channel_vocabularyand
get_alarm_historycarry typed schemas as well.
The remaining untyped tools are the PV-value tools and the display-lane tools.
- Three pre-gate refusal codes changed on the wire. A refusal raised before a write gate is
consulted writes no audit line, so it must not wear the gate's error code: otherwise an
un-audited refusal is indistinguishable from an audited gate DENY.OLOG_WRITE_DENIEDbecame
OLOG_WHOLE_MODE_REQUIREDfor ...
v0.2.0
Read-only MCP server for EPICS control systems. Pre-1.0 (active development — the tool/API surface may still change).
What it is
An MCP server that lets an assistant read and diagnose an EPICS control system, read-only by default and localhost-isolated. It sees several planes: live PVs (p4p — PVAccess + Channel Access), the ChannelFinder registry, the Archiver Appliance, the Phoebus Alarm config, the ESS Naming Service, .bob displays and IOC st.cmd/.db.
Highlights
- Core PV tools — read, batch-read, monitor, info, discover, validate connectivity (read-only; the single write tool is triple-gated and off by default).
- Cross-plane — provenance (
epics-crossplane), coverage audit (epics-coverage), and connection diagnosis (epics-diagnose) as both MCP tools and standalone CLIs. - Safety posture — no network egress until you widen the EPICS address list; REST planes stay disabled until their
*_URLis set; opt-in path boundary.
This release
A large internal quality remediation landed for 0.2.0: the CLI⇄tool orchestration was unified, the four REST clients now share one retrying-session/GET substrate, the service layer no longer imports the tool layer (enforced by a guard test), the concurrent batch fallback was hardened, and coverage rose to ~93% (357 tests, mypy --strict, ruff clean).
See README.md, ARCHITECTURE.md, and CHANGELOG.md.