MCP server
clockwright ships an MCP server that hands the engine to an agent as four tools:
describe_schema, validate_model,
run_model and compare_models.
It is a thin wrapper around the cwright binary. It runs the engine as a subprocess, carries the
engine's messages through unchanged, and adds nothing of its own to the arithmetic — every number
it returns was computed by the engine. The only things it derives are refusals.
⚠️ This is a local subprocess, not a hosted service. The server speaks JSON-RPC over stdio:
your MCP client starts it, talks to it on a pipe, and it invokes the cwright binary on the same
machine. There is no network call, no account and no telemetry anywhere in that path — see
Nothing leaves the machine.
What you need
the cwright binary | the build you received — see Installation |
| Python | 3.11 or newer |
| the server package | clockwright-mcp, which pins mcp==2.2.0 and pydantic==2.13.5 |
⚠️ The dependencies are pinned exactly, not bounded. The same argument applies here as to the engine's own toolchain: a results document is only comparable with another from the same build, and a version range would let two machines disagree about what an argument means without either of them noticing.
The package installs a cwright-mcp console script. python -m clockwright_mcp is equivalent, and is the form
to use if you would rather point your client at an interpreter than at a console script.
Configuring a client
The stanza below is the usual shape — .mcp.json for Claude Code, claude_desktop_config.json for
Claude Desktop, or whatever your client calls its server list. Use absolute paths: the client
starts the server as a subprocess and its working directory is not yours.
{
"mcpServers": {
"clockwright": {
"command": "/absolute/path/to/cwright-mcp",
"args": [],
"env": {
"CWRIGHT_BINARY": "/absolute/path/to/cwright"
}
}
}
}
The module form, if you prefer it:
{
"mcpServers": {
"clockwright": {
"command": "/absolute/path/to/python",
"args": ["-m", "clockwright_mcp"],
"env": { "CWRIGHT_BINARY": "/absolute/path/to/cwright" }
}
}
}
Environment
| Variable | Default | Meaning |
|---|---|---|
CWRIGHT_BINARY | unset | The engine to use. ⚠️ Never fallen back from — if it is set and is not an executable file, every tool fails with a message naming it. |
CWRIGHT_REPO | unset | A directory to look for target/release/cwright under, when CWRIGHT_BINARY is not set. |
CWRIGHT_TIMEOUT_SECONDS | 600 | How long one engine invocation may take. |
CWRIGHT_STORE | unset | A run catalogue directory. With one set, a model already run at the same replication count returns its recorded result instead of being re-run. |
With CWRIGHT_BINARY unset the server looks, in order, at $PATH, then at target/release/cwright under
$CWRIGHT_REPO, then under each parent directory of the installed package. A failure lists every path
it tried.
CWRIGHT_BINARY is used as given
Falling back from an explicitly named binary would silently run a different build than the one you
asked for, and two results from two builds are not comparable — which is the thing the manifest in
every results document exists to prevent. See
Determinism. So a CWRIGHT_BINARY that is not an executable file is an error,
never a hint.
A timeout reports no partial result
On expiry the tool fails and reports nothing. A run cut short is not a shorter run, it is a
different one. A long horizon or many replications can legitimately take longer than ten minutes:
raise CWRIGHT_TIMEOUT_SECONDS, or shorten horizon_seconds.
CWRIGHT_STORE is an operator setting, not a tool parameter
A run id is derived from the model's content hash, so a store lets an identical model at an identical replication count return its recorded result instead of being re-run. It is deliberately configured by whoever sets the server up: where results land is not the agent's decision.
The tools
| Tool | Runs the engine? | What it costs |
|---|---|---|
describe_schema | no | nothing |
validate_model | yes, cwright validate | milliseconds |
run_model | yes, cwright run | one replication sweep |
compare_models | yes, cwright compare | ⚠️ four replication sweeps — both arms are run twice, once for the summary and once to re-derive the paired per-replication values |
search_docs | no | nothing |
read_doc | no | nothing |
list_docs | no | nothing |
The intended loop is describe_schema → validate_model → run_model → compare_models. The full
description of each tool is in its MCP description, which is where the agent reads it; what follows
is for the human configuring the server.
describe_schema
Returns the model vocabulary so a document can be written without guessing: the fields a model may contain, their types and defaults, the nested types, the distributions, how a dispatch rule is expressed, the KPI names the engine reports, and a complete worked example.
| Parameter | Default | |
|---|---|---|
section | (all of it) | One of model, types, distributions, rules, enums, variants, kpis, refusals, example, example_yaml, notes. |
Omitting section returns everything, which is long. An unknown section name is refused rather than
answered with an empty result.
⚠️ The worked example is validated against the real engine by the server's own test suite. A schema description an agent writes a model from, that nothing ever validates, is a guess with a docstring.
example is the document as an object — hand it straight back to validate_model.
example_yaml is the same model in YAML, which is the
documented default for a file somebody writes by hand, because it takes comments.
⚠️ These tools take JSON only — an object, or a JSON string. That is not an omission: a tool
call passes a structured object, so there is no file and therefore no syntax to choose, which is
also why the engine reads - as JSON. Hand one of them YAML and the refusal says so by name
rather than giving you a column number. Reach for example_yaml when you are writing a file for
a person to read, or running cwright yourself; reach for example when you are calling these
tools.
⭐ The two are one model, and the server's own tests settle that two ways: the same model_hash
from the engine, which proves the YAML runs, and the same canonical JSON key for key, which is
the only one of the two that can see the sides naming different keys — a key carrying its
default is invisible to the hash.
validate_model
Checks a document and reports every problem at once, so a model can be repaired in one round rather than one fault per round. Runs no simulation. See Validation for what the validator checks.
| Parameter | Default | |
|---|---|---|
model | (required) | The model document, as a JSON object or a JSON string. |
Returns {valid: true, model_name, summary, model_hash, report} for a document the engine accepts,
and {valid: false, problems: [{where, what}], report} for one it does not.
⚠️ An invalid model is a result, not an error. Fixing it is the next move, and the problem list
is what to fix it from. report is the engine's original text and is authoritative if the
structured list and it ever disagree; a line that could not be parsed is carried as unparsed
rather than dropped.
model_hash is a content hash. Two documents with the same hash are the same model whatever they
were called, which is what makes a recorded result re-findable.
run_model
Runs a model for several replications and returns its KPIs with intervals.
| Parameter | Default | |
|---|---|---|
model | (required) | The model document, as a JSON object or a JSON string. |
replications | 10 | Independent replications. Minimum 2. |
kpi_names | (all of them) | Return only these KPI names. |
Every KPI comes back as {point, lo, hi, half_width, relative_precision, replications} and never as
a bare number. relative_precision is Infinity when the estimate is zero, which is normal for a
count of something that never happened. See The results document.
The response also carries manifest, warmup_discarded, trace_hashes, events_total,
wall_seconds, seconds_per_replication and engine_warnings.
warmup_discardedis per replication on purpose. Warm-up is chosen once across all replications by MSER-5 and every replication is truncated at the same point, so a per-replication answer is visibly wrong in the output rather than hidden behind a scalar.engine_warningsis not noise. A warning that the truncation is suspect means the horizon is too short to reach steady state — treat those estimates as unsafe rather than as a result.trace_hashesare one per replication. Two runs with the same trace hashes did the same thing event for event. See Determinism.- The replication ids in the manifest are what make a later
compare_modelspaired, which is worth far more precision than the same number of independent runs.
Fewer than 2 replications is refused: a single run has no spread, and a point estimate with no
interval is the one thing this engine will not report. A kpi_names entry the model does not report
is refused with the list of names it does.
A run's response can be large — a model reports three KPIs per resource plus one per product and
three per consumable, so a fab with a hundred tool groups produces several hundred intervals. Pass
kpi_names to select.
compare_models
Decides whether a candidate model is better than a baseline, on one KPI. Both arms run at the same
replication ids, so they are paired: the same randomness drives both and the shared noise
cancels in the difference. The reported pairing_efficiency is measured rather than assumed,
because common random numbers can inflate variance as well as reduce it. See
Experiments.
| Parameter | Default | |
|---|---|---|
baseline | (required) | The model to beat. |
candidate | (required) | The proposed variant, in the same form. |
kpi | (required) | Which KPI decides. |
indifference | ⚠️ (required, no default) | In the KPI's own units. See below. |
replications | 10 | Replications per arm. Minimum 2. |
lower_is_better | true | false for a KPI you want to grow, such as completed. |
full | false | Include the complete engine report, both arms' full KPI maps included. |
Returns the verdict, decided, the interval on the difference (candidate minus baseline), the
indifference applied, lower_is_better, pairing_efficiency, paired, replications_run,
next_step, both arms' manifests and their value of the compared KPI, and engine_warnings.
⚠️ indifference is required and this tool will not invent one
It is a required field in the published input schema, not merely in the Python signature, so a client that omits it is refused before anything runs. A missing, null, negative or non-finite threshold is refused too, with the reason.
This is the one design rule the rest of the server is arranged around. The threshold says what size of difference is worth acting on, and only the domain knows what that is. It is meaningful only relative to the model it is applied to: 3,600 seconds is a sensible threshold on a fab whose mean cycle time is 240,000 s, and declares every real improvement meaningless on a model whose cycle time is 6,000 s.
⚠️ The failure mode is not an error. A threshold that is too large produces a confident
NoMeaningfulDifference on a change that genuinely helped — and a search loop that reads it stops
exploring that direction for good. A tool that substitutes a plausible number turns a judgement into
a silent default, which is exactly the property this engine exists to protect. Derive it from this
model, or ask the person who owns the decision. Do not carry one between models.
The four verdicts, and why the fourth is not the third
| Verdict | What it means |
|---|---|
Better / Worse | The interval on the difference lies entirely beyond the threshold. Act on it. |
NoMeaningfulDifference | The interval lies entirely inside the threshold. The change is too small to be worth making. |
InsufficientEvidence | The interval straddles the threshold. ⚠️ The experiment could not tell. |
Every result carries decided. When it is false:
replications_neededcarries the engine's own estimate of what would settle it, when it gives one. ⚠️ It is parsed from the engine's sentence, never recomputed here — a second implementation of a definition is a second definition.more_replications_will_not_helpis true when the engine says the threshold is finer than the model can resolve. The honest moves then are to reduce variance, lengthen the horizon, or widenindifference.engine_gave_no_numberappears when the verdict isInsufficientEvidenceand no replication count could be parsed. It says in plain words not to read the result as "no difference" and not to read the point estimate as a result. The server does not compute a replacement number.
⚠️ Do not read the point estimate as a result when decided is false.
It accepts a narrower set of KPI names than run_model reports
compare re-derives per-replication values through its own match, which covers:
cycle_seconds completed released
cycle_seconds.<product-id> utilisation_pct.<resource-id> queue.<resource-id>
⚠️ That list was narrower until 2026-09-17, and this page said so. compare was widened to
match what run reports, and the server's copy of the list was not — so reworks,
availability_pct.*, queue_timer_violations, level.*, stockouts.* and stockout_wait_s.*
were refused here on a name the engine would have taken. The list is no longer typed anywhere: it is
read out of the engine's own source into schema.json. A name that is not a KPI is still refused
before running, because the engine would otherwise run both arms twice over before discovering
the same thing.
search_docs, read_doc and list_docs
⭐ This documentation ships with the server, copied verbatim at package time and stamped with
the engine version it was built against, so a connecting agent reads the pages for that build
rather than whatever is currently on a website. describe_schema is generated from the engine and
is the authority on what a field is called; these pages are why it matters and what goes wrong
without it.
| Tool | Parameters | |
|---|---|---|
search_docs | query, limit (8) | Ranked sections, each with its page, size and an excerpt. |
read_doc | path, section (optional) | One section's own text, plus the headings nested inside it. |
list_docs | — | Every page and heading, with sizes. The map rather than the territory. |
⚠️⚠️ Sections, not pages, and that is a requirement rather than an optimisation. The payload is
42 pages and 248 KB; returned whole it would flood the context of the agent that asked. So a heading
is served its own text and told what is nested below it — model/results.md is 22 KB and its top
section is 292 bytes with eight children you can ask for by name.
⭐ Ranking discounts words that are common in this corpus: model occurs in most sections and
counts for little, mttf occurs in three and counts for a lot. That discount is measured from the
payload, not a hand-written list of stop words.
Nothing leaves the machine
Every part of this path is local, and it is worth being precise about why:
- Transport is stdio. The client spawns the server as a child process and exchanges JSON-RPC over its pipes. There is no port, no socket and no address to configure.
- The engine is another local process. The server invokes the
cwrightbinary you pointCWRIGHT_BINARYat, feeds it the document, and reads its output back. - There is no callback of any kind — no registry lookup, no update check, no telemetry.
Paired with a model running on your own hardware, the whole loop stays there: the routes, the breakdown histories and the order book never leave the machine. That matters because those are exactly the things an agent needs to see, and exactly the things nobody sends to a third party.
Limits
⚠️ Four of the engine's eight verbs are exposed, by seven tools — the other three read the
documentation and run nothing. simulate,
resume, trace, propose and ls are
not. Checkpoint/resume and the flow records are genuinely useful and are simply not built here;
propose is compare with a variant document applied first, and exposing it would mean teaching an
agent a second document format. Those verbs remain available on the command line.
⚠️ It has not yet been driven from inside a shipping MCP client application. It has been exercised over a real stdio session against the real binary, and its test suite runs against the real engine, but "we ran it end to end inside Claude Code" is not a claim being made here.
There is no concurrency control. Each tool call spawns one engine process and waits. A long run
blocks that call for its duration, up to CWRIGHT_TIMEOUT_SECONDS.
A run's response can be large. Pass kpi_names to run_model, or section to
describe_schema, to keep an answer small.