Skip to main content

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 binarythe build you received — see Installation
Python3.11 or newer
the server packageclockwright-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​

VariableDefaultMeaning
CWRIGHT_BINARYunsetThe 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_REPOunsetA directory to look for target/release/cwright under, when CWRIGHT_BINARY is not set.
CWRIGHT_TIMEOUT_SECONDS600How long one engine invocation may take.
CWRIGHT_STOREunsetA 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​

ToolRuns the engine?What it costs
describe_schemanonothing
validate_modelyes, cwright validatemilliseconds
run_modelyes, cwright runone replication sweep
compare_modelsyes, 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_docsnonothing
read_docnonothing
list_docsnonothing

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.

ParameterDefault
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.

Which syntax belongs to which surface

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.

ParameterDefault
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.

ParameterDefault
model(required)The model document, as a JSON object or a JSON string.
replications10Independent 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_discarded is 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_warnings is 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_hashes are 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_models paired, 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.

ParameterDefault
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.
replications10Replications per arm. Minimum 2.
lower_is_bettertruefalse for a KPI you want to grow, such as completed.
fullfalseInclude 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​

VerdictWhat it means
Better / WorseThe interval on the difference lies entirely beyond the threshold. Act on it.
NoMeaningfulDifferenceThe interval lies entirely inside the threshold. The change is too small to be worth making.
InsufficientEvidenceThe interval straddles the threshold. ⚠️ The experiment could not tell.

Every result carries decided. When it is false:

  • replications_needed carries 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_help is 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 widen indifference.
  • engine_gave_no_number appears when the verdict is InsufficientEvidence and 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.

ToolParameters
search_docsquery, limit (8)Ranked sections, each with its page, size and an excerpt.
read_docpath, 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 cwright binary you point CWRIGHT_BINARY at, 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.