Skip to main content
View as Markdown

Letting Your AI Edit the Config

Switch on the four sovrium mcp tools that read, replace and undo the files your config is made of — what turns them on, every reason a write is refused, and how to go back.

Set MCP_CONFIG_WRITE=1 and four more tools appear, able to change the config file itself. It is off by default, and it is an environment variable rather than a config key on purpose: a config that could authorise its own editing would be a config that authorises itself.

It is honoured only when both hold — the variable is set, and the project directory was named explicitly, by --project or an inherited SOVRIUM_PROJECT_DIR. A session that fell back to the working directory gets the reads only and says so on stderr. A write surface confines itself to one folder, so the folder has to be one somebody chose on purpose rather than one a client happened to start in.

app.json
{
  "mcpServers": {
    "sovrium": {
      "command": "sovrium",
      "args": ["mcp", "--project", "/Users/me/apps/crm"],
      "env": { "MCP_CONFIG_WRITE": "1" }
    }
  }
}

On a deployed app the variable does nothing. The write tools are stdio-only and are never registered on an HTTP endpoint. Setting it there is not an error — the instance boots and serves normally — but the boot warns that it bought you nothing and names this verb instead, because an operator who believes they enabled config writes over the network has not.

Tool Arguments Returns
_config_list_files none { files: [{ path, sha256, bytes }] }, each path project-relative
_config_read_file path { path, content, sha256 } — the bytes, verbatim
_config_write_file path, content, expectedSha, acknowledgeDataLoss optional { path, sha256, snapshot, reloadHint }
_config_undo none { snapshot, files } — which snapshot, and what it changed

The two readers carry readOnlyHint. Both writers are marked destructive, so a client that asks you before running a destructive tool will ask — overwriting a file is a destructive update of that file whatever the tool then refuses to do to a table, and undo overwrites several at once.

Whole file bytes, never a patch. There is no config writer here and no serialiser: content replaces the file, so your comments, key order, anchors and blank lines survive an edit untouched. The cost is that the caller must read before it writes, and expectedSha is what makes that cost real rather than advisory.

What a write is refused for

Every write runs the same ordered checks, and each refusal names what it refused. The cheap structural ones come first, and the ones that open a database come last.

  • Outside the project. The path is resolved against the project directory and must land inside it.
  • Not a config file. .yaml, .yml and .json only, and never a symlink — a symlink can point anywhere, including out of the folder.
  • A protected location. .env and friends, .git/, .claude/, the data directory, and the template marker. A tool that can write .env is a credential-writing tool; one that can write .git/ rewrites history.
  • A stale expectedSha. The digest you edited against no longer matches the file, so something else saved over it — very possibly you, in your own editor. The refusal says to re-read, because an assistant told only "no" retries the identical call forever.
  • It would not decode. The candidate is overlaid in memory onto the resolved $ref graph and the whole app is decoded before anything is written. An invalid config never reaches the disk; the findings come back instead, in the vocabulary _config_validate already speaks. This is what makes editing one split-out partial safe — it is judged as part of the app it belongs to rather than as a document that happens to parse.
  • A new reference out of the folder. A candidate that introduces a $ref resolving outside the project directory is refused, or the file being read could walk out of the folder the file being written may not leave.
  • The live database would reject it. Where a server is running, the change is put through the same migration planner sovrium migrate --dry-run reports from, and a refusal there is the write's refusal — before the file changes rather than after the server has stopped trying to apply it.
  • It drops data. A candidate that introduces allowDestructive: true needs acknowledgeDataLoss: true alongside it, and the tool never sets either flag for itself. Dropping a column deletes the rows in it, and that is your decision every time.
  • It would renumber a table. A field id is optional, and an omitted one is the field's position. Inserting a field above one of those shifts every id after it and re-points the data behind them, so the insert is refused until the table's ids are explicit. The same insert into a table that spells every id out is accepted, because nothing moves.

Going back

An accepted write copies the pre-write state into the history described in Undo and Reset before it changes a byte, so _config_undo has somewhere to go even when no server is running and nothing has ever reached "accepted". It skips the copy when the newest entry already holds those exact bytes, which is why a watched project ends up with one entry before the edit and one after rather than three.

_config_undo restores the most recent snapshot whose files differ from what is on disk, and answers with the files it changed. When none differs there is nothing to go back to, and it refuses rather than reporting a success that changed nothing.

Undo puts the file back unconditionally. Whether a running instance follows it is a separate question: reverting a field you added is a column drop, so the watcher's own pre-flight refuses that reload without allowDestructive, keeps the configuration it is already serving, and publishes the reason. Your app stays up and your rows stay where they are.

A write does not make anything live. It puts bytes on disk; _config_status is how you find out whether an instance took them. Calls are answered one at a time, in the order you sent them, so a write followed by an undo happens in that order.

The four read tools, how a client is pointed at the server and which config it reads are in Your Config over MCP.

Behaviour

sovrium mcp Lets a Local AI Edit the Config File, Under Eight Bounds

  • With MCP_CONFIG_WRITE unset, no write tool is listed, while the four read tools in the same list prove it matchable
  • With the flag and an explicit project, all four write tools are listed, the two writers annotated readOnlyHint: false
  • With the flag but no explicit project directory, no write tool is listed; the same run with --project lists all four
  • An HTTP instance booted with the flag says the variable is ignored, naming MCP_CONFIG_WRITE and sovrium mcp
  • _config_list_files returns the whole $ref graph, project-relative, each sha256 matching the file's real digest
  • _config_read_file returns the bytes and the same sha256 _config_list_files published for that path
  • A write whose path escapes the project directory is refused and the file outside it is left byte-unchanged
  • A write to .env, .git/, .claude/ or the data directory is refused, each naming the location it refused
  • A write to a non-config extension is refused, and a write through a symlink is refused
  • A write carrying a stale expectedSha is refused, tells the caller to re-read, and leaves the file byte-unchanged
  • A candidate that fails to decode is refused with findings, and the partial it targeted is byte-unchanged on disk
  • A candidate introducing a $ref that resolves outside the project directory is refused, naming the offending reference
  • With a server running, a change the live database would refuse is refused before the file changes, naming the table
  • A candidate introducing allowDestructive: true is refused without acknowledgeDataLoss, accepted with it, and never sets it
  • Inserting a field mid-array is refused citing field-id-implicit; the same insert with explicit ids is accepted
  • An accepted write lands the candidate bytes, returns sha256 + reloadHint, and records the PRE-write state in the history
  • _config_undo restores the most recent differing snapshot and names the files it changed; it refuses when there is none
  • The loop closes under --watch: a valid write is served, _config_status reports the new hash, and undo returns the old one
  • One stdio session: list, read, every refusal, an accepted write, and undo — then the live reload loop end to end

Last updated September 27, 2026

This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta. Contributions and corrections are welcome.

Built with Sovrium