satz mcp
The estate, over the Model Context Protocol. An MCP client — Claude Code, Cursor, any
other — drives satz. satz calls no model, needs no API key and is deterministic; only
the tools marked openWorldHint below reach the network.
satz mcp # read-only, the default
satz mcp --allow read,write # …and may write files in the estate
satz mcp --allow read,write --self-gated
It speaks JSON-RPC on stdio and is started by the client, not by you.
Started by hand, it exits immediately, because the client speaks first:
satz mcp: stdin closed before the client said hello — nothing to serve, exiting.
To exercise it by hand, pipe a handshake in:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| satz mcp --root /path/to/estates
What an agent may do
Three capability groups, granted independently. Each covers a different kind of consequence, not a severity level:
| group | what it covers |
|---|---|
read |
compile and report. Nothing is written, nothing external runs |
write |
writes files inside the estate — hcl/, adopted ids, the preset library |
exec |
runs an external tool, or changes a live organisation |
--allow sets a ceiling the client cannot raise. With --self-gated the client
may lower its own level at runtime through the satz_restrict tool and can never
raise it again — so an agent can prove it stayed read-only for a phase of its own work.
The ceiling bounds this server and nothing else. An agent that also has a shell of its
own — Claude Code's Bash tool, say — can run any satz command there, apply included,
with the rights of the user who started it; what the ceiling is for is an agent whose
only way to satz is this server.
At every level self-update is not exposed (it replaces the binary), and
every path argument is confined to the directory the server was started in:
use "…" resolves through include_dirs, so an estate can pull in other files.
Resources — what an agent reads before it writes
A client that only speaks MCP has no repository, and the tool schemas do not describe the language. The server serves the documentation:
| resource | what it is |
|---|---|
satz://guide |
satz llms — the working subset, the three grant forms, and the order to call the tools in. Read this before writing a .satz file. |
satz://reference |
the complete language reference |
satz://presets |
the preset library and its provenance rules |
They are compiled into the binary, so they match the server's version and need no
path. The instructions returned at initialize name the guide. A unit test parses
every satz block in the guide.
Tools
| tool | group | what it answers |
|---|---|---|
satz_estates |
read |
which estates this server can open: every config.toml under its root, with the estate files beside it — each with its deployment_mode as the compile reads it, or, for one satz_open refuses, refused with the reason and no mode |
satz_open |
read |
open one for the session — its config.toml and its main .satz. Answers with what it resolved, including the identity that estate's live tools will run as — runs_as, null when they run as the credentials themselves (local mode, or satz --no-impersonate mcp) |
satz_require |
read |
which controls of a catalog the declared estate satisfies, from its packs' claims, and per control its measures: every pack that claims it, included or not, with the gcloud commands that meet and check it without satz and the risk its pack states. Offline |
satz_questions |
read |
every question the estate's packs declare with its state — answered when the estate's own params bind it, else unanswered with the default the pack offers or blocking when none is possible, and each param question's declared shape (string | number | bool | list | map) — and summary.complete, the gate satz bootstrap and satz transpile --apply refuse on |
satz_interview |
read / write |
the interview: the open questions (or all, with filter: all), each with its pack's description and its offer. With write: create writes the estate first, answers writes what the human decided (for a choice, the option's param name, or none where the choice is not required), accept_defaults writes every offer — and the report comes back as it now stands. An answer whose shape contradicts the question's shape is refused and nothing is written. create writes the day-0 estate: eighteen questions, and every pack's use line commented out under the phase it can be adopted in, from the pack-graph.json in presets_dir — with none there, the estate is written without pack lines. Answering a pack's question true switches its line on as satz_add_pack does — uncommented, or written where the pack graph places it — so an answer and what the estate uses cannot disagree; like it, the answer is refused while a pack it needs is off, and an answer that would leave an estate satz refuses writes nothing. notices carries what those answers opened: a pack that names a command to run once it is on (§6.15) — its text, the run, and the param to answer true through answers once the command has run. contributes carries what the packs those answers switched on add to other packs' list params (language: contributes_<param>) — each with the pack, the param, its values, and applied, false when nothing the estate uses declares that param. A call that answers nothing returns neither. core says what the call did about a commented-out use "presets/estate-core.satz" line — the one satz init writes without --interview: a session with write switches it on; otherwise why it was left. satz interview |
satz_packs |
read |
every pack the pack graph offers, as the estate has it — the rows satz-studio's Packs view shows: the gate with the estate's answer, the library's default and the value they give; the line (active, ungated, commented, absent, forked, misplaced) and its number; whether it deploys; what it requires (each with met), what it is required_by and what it excludes; the notices it carries, each with its severity and acknowledged; what it contributes to other packs' list params while it deploys, each param with its values; and the compile's findings about it. A use the graph does not know is unmanaged; with no pack-graph.json in presets_dir, note says so. The same value satz packs --format json prints |
satz_check_consumer |
read |
a project's HCL beside the estate, held to the estate's interface as satz check-consumer holds it: dir names the project's directory of .tf files under the server's root. findings lists, at the project's file and line, an attachment resource onto the estate's object that no attach point (export … attach [ … ]) allows, an authoritative grant (*_iam_policy, *_iam_binding) or an organisation policy on a node the estate manages, and a resource the estate declares too, by natural key; an empty list is a pass. resources counts the project's resource blocks read. Offline; writes nothing |
satz_add_pack |
write |
switches a pack on by its gate or path, as satz add-pack does: the gate bound true, the line made active where the graph places it, the packs that follow its gate with it. Refused, naming them, while a pack it needs is off (with_requirements switches those on where the graph names one) or one it excludes is on. The edited estate is compiled and restored when it does not compile. Returns what was bound, which lines moved, the questions that opened, in notices the commands the packs it switched on ask to be run — each acknowledged by answering its param true through satz_interview once it has run — and in contributes what those packs add to other packs' list params: billing_export adds Google's export account to the CIS baseline's allowed, for one. satz add-pack prints the same lines |
satz_remove_pack |
write |
switches a pack off, as satz remove-pack does: the gate bound false, the line left. Refused, naming them, while a pack that needs it is on (cascade switches those off too) or while its line is not gated on its gate. The edited estate is compiled and restored when it does not compile |
satz_triage |
read |
a Prowler export's FAILs sorted into buckets A–E against what the estate claims |
satz_prowler |
read |
the Prowler invocation this estate needs — the frameworks it is HELD TO and those its packs CLAIM, together; the projects; and an output path named for the UTC minute, so no two scans share a file; unresolved_projects names each project left out because its id is built from a reference to another resource. It prints the command; nothing runs the scanner |
satz_transpile_check |
read |
compiles in memory and reports what it would emit — writes nothing; findings carries what the compile said, each at its file and line, by kind and subject — the pack, the notice's param, the action's name, the hcl block's file:line — with message, the sentence, and fix, the command that answers it, where one command does. A finding a tier silenced is in the list too, carrying silenced with that tier and its reason, so an agent sees what a human's output leaves out. A refusal is an error result that carries the same list — the front end's too, as one front-end finding — so an error is shown at its line rather than parsed out of the sentence; its text block is the errors as the CLI lays them out. satz transpile <estate> --check --format json prints this object |
satz_check_presets |
read |
which packs are clean, behind upstream, locally edited, or changed only in the questions they ask |
satz_fmt |
read |
Satz text in, the canonical layout out — two-space indent, = aligned over a run, one list item per line. No path and no write: the client holds the file. satz_review_pack refuses a pack that is not formatted, and this is what formats it |
satz_review_pack |
read |
one pack against the library's bar — it parses, is formatted, its header says what it is, its id is its file name, its version has a changelog row, it carries no value shaped like private data (kind private-shape, one finding per token), it declares no membership, it runs no legacy constraint beside its managed replacement, every type it emits has a prerequisite row, it compiles, and every string param it leaves "" has a question. Folded into a synthesised estate unless against names one. Findings anchored to file and line |
satz_report_compliance |
read |
the goal view joined with live verification through Cloud Asset Inventory, attestations and optional Prowler corroboration, each control with the same measures as satz_require, read as the estate's IaC service account (cloud mode) or as the credentials themselves (local mode). Checkov's column is not served here — satz_scan_checkov runs Checkov — and neither is --fail-on: the report carries every status, and an agent judges them. framework is optional: named, the answer is that catalog's report; omitted, it is {frameworks, reports} — one report per framework the estate is HELD TO (compliance_frameworks) |
satz_whoami |
read |
both halves of the identity — the ADC account, and what the open estate's calls run as: its mode, the service account it declares, and whether that account is impersonated — with the live checks that decide whether the next call works: may this credential become that account, is the quota project reachable, does it hold the permissions the estate's resource types need (permissions, each missing one named with its role). The first thing to check when a live call is refused |
satz_transpile |
write |
compiles the estate and writes its OpenTofu HCL into hcl_dir, as satz transpile does; written lists the files; inputs (name → value as text) binds a template's inputs for the run, as --input <name>=<value> does |
satz_adopt |
read / write; out also write |
every declared resource resolved against the live organisation, as the estate's service account: per row whether it would be imported, moved in the state, is already managed, is created by the next apply (an IAM grant the live policy of its parent does not hold, or whose parent does not exist yet), or cannot be resolved, what it matched on, and the Satz line that declared it. rows carries the rows that ask for something — a decision, a state move, an import, in that order — and at most fifty of them; rows_total, rows_omitted and note say what is not there and where the rest is, and out writes the whole report as JSON to that path under the root. With execute (write) the verified ids are written into the estate as "import-id", refused while any row is unanswered or a live object is declared twice; written and hints are a line per resource and are capped the same way, with written_total and hints_total beside them. tofu import, a state move and activating a managed constraint stay on the command line |
satz_update_prerequisites |
read / write |
what the estate's resource types oblige it to declare and it does not: the roles its IaC service account is missing (with the fewest roles that close the gap) and the APIs not enabled on the project their resources' calls are billed to (with the project and the types that need each), plus the emitted types the table has no row for. Offline. It WRITES both into the estate file and re-checks — a gap that survives the write restores the file — so the default needs write; report_only lists the gap and needs only read |
satz_merge_presets |
write |
the reconciling update: what was installed, taken as doc/format only, forked to X.local.satz with the estate repointed, adopted in place, deferred or refused — as events in the walk's own order, with the counts and attention. adopt takes upstream in place for the packs named (all for every pack merely behind) and the answer carries the emission delta; report_only writes nothing. It writes the commented use line for a pack the library has that the estate has none for, so a new pack's question never binds an answer that nothing emits, and last writes the roles and APIs the estate's packs need as satz_update_prerequisites does — a prerequisites event, at validation level error too |
satz_get_presets |
write |
the upstream library into the open estate's presets_dir: missing files installed, identical ones left, changed ones the estate does not use refreshed; a pack the estate uses that upstream changed is refused unless force. pristine_dir copies from a library under the root instead of downloading. presets_dir must be inside the root |
satz_remediation_items |
read |
the remediation dossier's items for an estate and a Prowler export — triaged, deduplicated, joined per (control, resource) — with the dossier_sha256 authored values must name. The worklist for the [Authored] columns. checkov names a Checkov JSON report under the root — the one satz_scan_checkov writes to its out — and its findings join the dossier's; the tool reads it and runs nothing |
satz_remediation_annotate |
write |
writes authored values (what_why, recommended_fix, owner, effort, phase, quick_win, risk_acceptance, and the mandatory authored_by and authored_at) per item id into <out>/authored.json, merged with what is on file, and renders the run there with the [Authored] columns filled. It takes the prowler export and the checkov report the items were built from. Refused when the hash is not the current dossier's, an id is unknown, or an entry names no author |
satz_scan_checkov |
exec; out also write |
Checkov over the HCL in hcl_dir — the counts, and every failed check with the Satz file and line that declared the resource. Scans what is written: transpile first. Runs checkov, else uvx checkov, with its output captured. With out it writes Checkov's JSON report to that path under the root, the file satz_remediation_items and satz_remediation_annotate read as checkov |
satz_restrict |
— | lowers this session's level; only with --self-gated |
Each returns the same value the corresponding --format json command prints, with one
exception: satz_adopt, whose table has a row per declared resource, returns the rows
that ask for something and says how many it left out — a terminal has no output limit and
a client has (result size).
Which commands an agent can run is a decision per command. MCP_PARITY
(src/mcp.rs) names every CLI command with the tool that serves it or the reason
none does, and a command in neither column fails cargo test — the same way every
command must declare an identity. What is not served, by class: the commands
that hand stdio to tofu (plan, apply, hcl-init), the day-0 ones that run as
the human (init, bootstrap), the one-off that moves an estate between deployment
modes as the operator's own credentials (migrate), the ones that write to an
organisation (run-actions, adopt-org-policies), the live sweep that creates an
estate from what exists before there is one to open (import), the specialist
org-policy tools the compliance plane answers for (export-, diff- and
report-organizational-policies), the maintainer refreshes of shipped data
(map-types, update-schema, doc-packs, pack-graph), the tofu-workflow plumbing
(scan-plan, generate-migration), what decides a human's own output (silence — an
agent is handed every finding, the silenced ones included and marked), the client
configuration that sets this server's own ceiling (mcp-config), the interface plane's
commands, which have no tools (interfaces is the report satz-studio reads through the CLI, check-request runs
in a project's pipeline on the pull request that vendors the file), and the terminal
affordances (completion, open-readme, self-update, help). Every one of them except the
terminal affordances, mcp itself and lsp is named with its reason in the
instructions the server returns at initialize, so an agent asks a human for it. The table in src/mcp.rs
is the full list, with a reason per command. The tools
the table has no command for — satz_open, satz_estates, satz_restrict — are
the session and capability plumbing a terminal does not need.
A tool the level does not permit returns a tool result with isError, not a
protocol error, so the agent can continue. A missing argument, or one of the wrong type,
is refused the same way: an isError result that says what did not deserialize
(missing field `framework` ), not a JSON-RPC invalid_params error.
satz_report_compliance reads live and writes nothing. The CLI command appends
every run to the append-only evidence history, the audit trail of reports someone ran;
the tool does not append.
The rows are data, not a rendered table. Each witness is an object — address,
live state, the id it matched, and declared_at, the file:line of the Satz that
declares it. Each control carries a responsibility (inherited · customer ·
shared · satz-managed · unassigned) and the report carries the estate's commit
and whether the tree was dirty. An agent renders the audit list, the spreadsheet or
the remediation commands from these fields. declared_at points each witness at the
Satz line that declares it, at the report's commit.
A report that verified nothing says so. live is whether the inventory was
actually READ, not whether it was asked for; live_status says which of the five
outcomes it was — verified, skipped (--no-live), no-organization-id,
no-witnesses, unavailable — and warnings carries the reasons, in the words the
command prints to its terminal. The report degrades to unverifiable witnesses rather
than failing the run; live and live_status tell a caller that cannot read stderr —
an agent, a pipeline reading --format json — whether the witnesses were verified.
Undeclared exemptions have their own field. For an estate that declares the exemption
key, exemption_bindings lists the live bindings of that key the estate does not
declare, and each row's undeclared_exemptions the ones that let its witness policies
out. Its status says whether the bindings were read; undeclared is null unless they
were (language §8).
Structured output
Every data tool returns its report as structuredContent and publishes the
report's outputSchema in tools/list. A client gets a typed value it can index,
and knows the shape before it calls. The text block of the same result carries the same
JSON, for a client that reads only text.
Each report is a JSON object with an object schema, as MCP 2025-06-18 requires, and is
the value the command's --format json prints: satz_triage returns {"rows": […]},
as satz triage --format json does.
Where a report is too long for a client to read, what it leaves out is a field of the
report rather than a difference between its two renderings: satz_adopt carries
rows_total, rows_omitted and a note naming the file its out wrote, and the text
block and structuredContent carry the same trimmed object.
Annotations
--allow sets what the server permits. Tool annotations tell the client what
it may run without asking:
| annotation | on |
|---|---|
readOnlyHint: true |
satz_open, satz_estates, satz_require, satz_questions, satz_packs, satz_check_consumer, satz_triage, satz_prowler, satz_transpile_check, satz_check_presets, satz_review_pack, satz_fmt, satz_report_compliance, satz_whoami, satz_remediation_items |
readOnlyHint: false, destructiveHint: false, idempotentHint: true |
satz_update_prerequisites — report_only is free, the default writes the estate and is refused below write; satz_transpile — it writes, but re-running it converges; satz_remediation_annotate — the same values written twice leave the same run; satz_adopt — reading is free, execute writes the estate and out writes the table, and both are refused below write; satz_interview — reading is free, create/answers/accept_defaults write the estate and are refused below write; satz_add_pack and satz_remove_pack — they write the estate, and the same call twice leaves it as the first did; satz_scan_checkov — it runs an external program, and uvx downloads it first, so a client asks before running it, and out writes its report; satz_merge_presets — its report is free, and the write converges; satz_restrict — it lowers this session's level and nothing else |
destructiveHint: true |
satz_get_presets — with force it overwrites packs the estate uses |
openWorldHint: true (also) |
satz_merge_presets — without pristine_dir it fetches the upstream library |
openWorldHint: true |
satz_check_presets, satz_report_compliance, satz_whoami, satz_scan_checkov, satz_adopt, satz_get_presets, satz_merge_presets — the ones that can reach the network (uvx checkov fetches Checkov) |
Without annotations a client either prompts on every read or runs a write without asking. The ceiling decides what is possible; the annotations decide what runs without a prompt.
Claude Code
claude mcp add registers the server. satz takes flags of its own, so they go after
--, which is where Claude Code's arguments end and the server's begin:
claude mcp add satz -- satz mcp --root /path/to/estates --allow read
Where it is written
--scope (-s) decides the file, and the file decides who sees the server:
| scope | the server is available | written to |
|---|---|---|
local (the default) |
to you, in the project you added it from | your own file, under that project's path |
user |
to you, in every project | your own file, at the top level |
project |
to everyone who clones the repository | .mcp.json at the repository root, committed with it |
Your own file is .claude.json in your home directory:
| platform | path |
|---|---|
| macOS | ~/.claude.json |
| Linux | ~/.claude.json |
| Windows | %USERPROFILE%\.claude.json |
CLAUDE_CONFIG_DIR moves it: set it, and the file is read from inside that directory.
A project server is the same JSON in .mcp.json at the repository root on all three
platforms, which is the scope that puts an estate's server in the estate's own repository.
Written by satz
satz mcp-config prints the block for one estate, and --write puts it in .mcp.json
in the directory holding the estate's config.toml — the project scope above, which is
where an estate's server belongs:
satz mcp-config C0example.satz # print it
satz mcp-config C0example.satz --write # write .mcp.json beside config.toml
satz mcp-config C0example.satz --allow read,write --write
satz fills in the three values a hand-written block gets wrong. The binary is the
satz that prints the block, by absolute path, so the client needs no PATH of its own.
The root is the directory holding the estate's config.toml, canonicalised. The ceiling is written
out even when it is the default, because a block that omits --allow grants read
without saying that anyone chose it.
--write merges satz's own key and leaves every other key in the file as it is, in the
order the file has them; satz's key keeps its place when it is replaced and goes last
when it is new. A
satz key already there with other arguments is printed and refused — --force
replaces it — and a file that is not JSON is refused and left alone, since satz cannot
merge into what it cannot read. A second run writes nothing.
Written by hand
{
"mcpServers": {
"satz": {
"type": "stdio",
"command": "satz",
"args": ["mcp", "--root", "/path/to/estates", "--allow", "read"]
}
}
}
command is looked up on PATH, so the installer's ~/.local/bin/satz resolves by
name; an absolute path resolves anywhere. On Windows the PowerShell installer puts
satz.exe in %USERPROFILE%\.local\bin and on the user PATH. A Windows path is JSON, so its
separators are escaped — "C:\\Users\\you\\estates" — or written as /.
Give --root an absolute path. The client starts the server, so the working
directory is the client's rather than the one you typed in, and a relative root resolves
against it. The root is one directory holding many estates: one server serves a fleet,
and the client opens an estate per call.
Check it
claude mcp list health-checks every server and prints the command behind each;
/mcp in a session shows one server's tools and reconnects it. A server from .mcp.json
is listed as pending until it is approved, so cloning a repository does not start a
process without consent.
Result size
Claude Code cuts a tool result longer than MAX_MCP_OUTPUT_TOKENS (25,000 tokens unless
the variable is set in its environment), and what it cuts is no longer JSON. MCP asks the
text block to repeat structuredContent, so every row of a result costs twice its own
JSON.
A result that carries a row per declared resource therefore carries what the caller has
to act on rather than the whole table: satz_adopt returns the rows that ask for
something — a decision, a state move, an import — most urgent first and at most fifty of
them, with rows_total, rows_omitted and a note that states both numbers and names
the two ways to the rest: out, which writes the whole report as JSON to a path under
the root, and satz adopt <estate>, which prints every row in a terminal, where there
is no limit. The trimming is in the report, not in the rendering: the text block still
equals structuredContent, and both say what they leave out.
satz_report_compliance returns the largest results — around 19,000 tokens of verdicts
and witnesses for one organisation against CIS GCP 5.0, plus the measures of every
control, the library's gcloud routes and risks, about 57 KB more — and they grow with the
estate and the catalog. It is the whole report or nothing: an
evidence report that omits evidence is a different document, and it is the value
satz report-compliance --format json writes. A call with no framework returns one
report per framework the estate is held to, so its result is that size multiplied by
however many it names. Where an estate outgrows the limit, name one framework per call,
raise MAX_MCP_OUTPUT_TOKENS in the client's environment, or run the command and read
the file it writes.
scripts/mcp-probe.py prints each tool's largest result in bytes and in estimated
tokens, and fails the run on one over --limit-tokens
(housekeeping).
Run the same command in a terminal to see what the client cannot show you: it holds the line waiting for a client, and says so when stdin closes.
Claude Desktop
Claude Desktop reads one file, which holds every server a person has:
| platform | path |
|---|---|
| macOS | ~/Library/Application Support |
| Windows | %APPDATA%\Claude\claude |
| Linux | ~ |
An entry in it is a command and its arguments:
{
"mcpServers": {
"satz-C0example": {
"command": "/Users/you/.local/bin/satz",
"args": ["mcp", "--root", "/Users/you/estates/acme", "--allow", "read"]
}
}
}
satz mcp-config C0example.satz --client claude-desktop prints that block, and --write
merges it into the file at the path above — --file names another. The key carries the
estate's name because one file holds every server: a second estate is a second key beside
the first, and --name <KEY> writes a key of your choosing, which is what two estates
with the same file name under two different roots need.
satz owns that one key. Every other server in the file, and every setting beside
mcpServers, is read, kept and written back as it was, in the file's own key order; the refusals are the same as for
.mcp.json. Claude Desktop reads the file when it starts, so restart it after a write.
The MCP SDK
satz serves MCP through rmcp. The smoke matrix
drives the server as a client at protocol revision 2025-06-18, the revision every
step of it speaks. The server answers a client's revision from 2024-11-05 to
2025-11-25 with the same revision, and any other with 2025-11-25.
scripts/mcp-probe.py drives every tool the way a client does, reading the server's
stdout itself rather than through an MCP SDK, because an SDK drops a line that is not
JSON and a test built on one cannot see a leak. It runs by hand, not in CI
(housekeeping).
stdout is the protocol
Everything satz says to a human — the version banner, emitter warnings, the
credentials: line — goes to stderr. Under MCP a stray line on
stdout corrupts the stream, and the client reports nothing useful. The smoke matrix
asserts that every line the server emits parses as JSON-RPC, and scripts/mcp-probe.py
charges any line that does not to the call in flight. The background update
check does not run under mcp or lsp at all, and the Update available notice
every other command may print is on stderr, so --format json output starts with
the JSON.
The matrix runs without credentials, so it never reaches a live tool call. Two
unit tests cover that path: one scans src/mcp.rs, the other scans the code a tool
reaches transitively — the token chokepoint gcp::access_token() and the announce
path. A new live code path on a tool's route belongs in the second one.
One server, a fleet, one identity per call
The server holds no estate until a client opens one. satz_open names a
config.toml and a main .satz; everything after works on that estate, under that
config — its presets, its schemas, its provider version. Call it again for the next
estate. satz_estates lists what is available under the root, so the first call is not
a guess at a path.
The root, given as satz mcp --root <dir>, is a boundary, not a configuration: every
config and estate a tool resolves must live inside it, and anything outside is refused by
name. It is the only thing the server is started with: estates do not share a
config.toml, so each estate's own config comes with satz_open, and satz mcp takes
no --config.
A path is judged against the root before anything else is said about it. A path outside
the root gets the same refusal whether or not something exists there, and the refusal
names the path as it was asked, not where a symlink leads; only a path inside the root is
told that it is missing. A path that does not exist yet — an output directory, an estate
satz_interview creates — is judged where creating it leads, with .. applied the way a
directory walk applies it. A relative estate name is read from the server's working
directory when a file of that name is there and inside the root; otherwise it names a
file in the config's yaml_dir. That is every estate argument — estate, and
satz_review_pack's against.
An estate argument also has to belong to the open config.toml, because the call runs
under that config's presets, include dirs and schemas: the nearest config.toml at or
above the estate's directory is the open one, or, with none above it, the estate lies in
the open config's yaml_dir. An estate of another config under the same root is refused,
naming both configs; satz_open with its own config is how to work on it.
Which identity, and who decides
A live tool runs as the estate it is working on — the one its estate argument names,
else the open one: for deployment_mode = "cloud", that estate's IaC service account,
exactly as the same command does from the shell. Nothing is configured and no tool sets
it. The ADC authenticates, and satz's first act is to exchange it for the account the
estate itself names — svc_iac_account + infra_project_name, the same derivation the
emitted provider block uses — read from the estate as it stands when the call is made.
satz_open reports the result as runs_as so it is stated rather than assumed, and
null there means the calls are the ADC identity itself — the estate impersonates
nothing, or the server was started as satz --no-impersonate mcp; its deployment_mode is the mode the compile reads, local when the estate
declares none.
An estate whose params do not parse, whose deployment_mode is neither local nor
cloud, or whose deployment_mode = "cloud" has no value for svc_iac_account or
infra_project_name, names no identity. satz_estates lists it with that reason as
refused, satz_open refuses to open it, and satz_whoami,
satz_adopt and satz_report_compliance refuse a call that names it, each with the
estate and the reason; none runs as the ADC identity in its place.
The identity is scoped to the call, not bound to the process, so one server works through a fleet. It is a scope rather than a mutable global because the server dispatches requests concurrently: a global changed underneath a call in flight would run one estate's tools with another estate's credentials. Work started under one estate finishes under it.
satz_whoami answers for the open estate, inside the same scope the other tools use, or
for the estate its estate argument names. With nothing open it answers for the ambient
credentials, as satz whoami does with no estate; an estate named with nothing open is
refused, because only an open estate's config resolves the name. Its estate object
carries what the terminal's runs as: line says: deployment_mode, the
service_account the estate declares (in local mode too), and impersonated — true
when the calls run as that account, false when they run as the ADC account itself (local
mode, or --no-impersonate). --no-impersonate outranks the scope: every tool then runs
as the plain ADC.
Not available
- No
tofutool.satz planandsatz applyinherit stdio — apply's approval prompt is interactive — and under MCP stdin and stdout are the protocol; a human runs them. Their API preflight runs with them, so nothing enables an API over this protocol either. What an agent gets issatz_update_prerequisites: itsenable_missing_apisholds onegcloud services enableline per project the estate is missing APIs on. satz adopt --execute --importis not exposed — thetofu import, the state moves and activating a managed constraint stay with a human;satz_adoptwrites the ids.satz report-compliance --checkovand--fail-onhave no counterpart —satz_scan_checkovruns Checkov as a tool of its own, andsatz_report_compliancereturns every row's status rather than an exit code.- No progress notifications.
satz_check_presetsdownloads the whole pristine library with no feedback to the client. satz interfacesandsatz check-requesthave no tool.interfacesis the report satz-studio reads through the CLI;check-requestruns in a project's pipeline on the pull request that vendors the file.satz_check_consumeris the interface plane's one tool, and it reads.