staal

Staal as an MCP server: design

Status: version 1 is built (staalc mcp, src/mcp/) and tested (tests/mcp_test.rs, tests/mcp_crash_test.rs, tests/mcp_sdk_test.rs). The design was agreed before implementation (phase 0 of the ecosystem plan) and revised after two outside reviews; the manifest format in section 2 is frozen for version 1. Where building it settled a detail the design had left open, this note says what was built. When the implementation and this note disagree, the note is updated in the same change.

Running it: staalc mcp <manifest.json> serves on standard input and output, the way an MCP client starts a local server; staalc mcp status <manifest.json> lists, beside a running server, the runs that are unfinished and each tool’s quota use.

0. Invariants

Everything below serves these, and a change that breaks one is wrong whatever else it does:

  1. The model asks; Staal decides. Nothing from the client becomes authority: not a grant, a limit, a secret, a model or a deadline.
  2. MCP is an entry point, not a second engine. A tool call is an ordinary Staal run, with the same checks, broker, journal and recovery as staalc run. The server adds bounds (quotas, concurrency, deadlines); it never removes one.
  3. Schemas inform; the runtime decides. A JSON Schema accepting arguments implies nothing: Staal’s own check of inputs is the one that decides.
  4. Nothing is done twice by accident. An action is performed once per run (the journal), and a call the operator marks as one logical operation is one run, however often it is sent (section 5, once).
  5. Nothing uncertain is guessed. An action that may or may not have happened waits for a person.
  6. What the server knows survives it. Every state that matters is on disk before anything depends on it, and every crash point has a recovery rule (section 7).

1. What it is for

staalc mcp serves Staal programs as tools to any client that speaks the Model Context Protocol: coding agents, desktop assistants and agent frameworks. A tool call starts one Staal run. The run’s grants, broker checks, budgets and journal apply exactly as they do under staalc run or lib.run; the server adds nothing a run could use to do more, and one thing that bounds it further: a quota across calls (section 6).

The client, and the model behind it, is untrusted. It chooses which tool to call and with what arguments. It never chooses a grant, a secret, a limit or a model.

Not in version 1: a remote server (Streamable HTTP and OAuth), resources, prompts, sampling, the tasks extension, and tools whose list changes while the server runs.

2. The manifest

The operator writes one JSON file. It is read once at start; any problem in it stops the server before it answers anything, with the same kind of message a run config problem gives. Unknown keys are refused at every level.

{
  "server": { "name": "shop-actions", "journals": "/var/lib/staal/mcp", "keep": 90,
              "max_concurrent_runs": 8, "notify": ["/usr/local/bin/notify-operator"], "notify_env": ["SLACK_WEBHOOK_URL"] },
  "model": { "provider": "anthropic", "model": "claude-sonnet-5-5", "api_key": { "env": "ANTHROPIC_API_KEY" },
             "timeout_ms": 60000, "retries": 2 },
  "tools": [
    {
      "name": "refund_order",
      "title": "Refund an order",
      "description": "Refund part of an order to the customer's original payment, within the shop's limits.",
      "program": "refund.staal",
      "entry": "main",
      "grants": {
        "pay": { "cap": "payments.stripe.refund", "each": { "amount": "50.00", "currency": "USD" }, "uses": 1,
                 "needs": { "get": { "cap": "net.http.get", "only": ["https://api.stripe.com/v1/payment_intents/*"] },
                            "post": { "cap": "net.http.post", "only": ["https://api.stripe.com/v1/refunds"], "idempotency_key": true },
                            "key": { "env": "STRIPE_SECRET_KEY" } } }
      },
      "net": { "timeout_ms": 10000 },
      "deadline": "120s",
      "max_concurrent_runs": 2,
      "once": { "by": ["order"], "keep": "30d" },
      "quota": { "window": "24h", "calls": 100, "money": { "amount": "500.00", "currency": "USD" } }
    }
  ]
}

At start, every program is checked (staalc check), and every tool’s config is checked by building a run config from it with placeholder inputs (as staalc config makes them) and validating it as staalc run would, without running anything. A program with check errors, or a config that would be refused, stops the server.

Two things a program could do under staalc run are refused in a tool, because they would reach the protocol stream or wait without end:

print stays allowed. In a tool, printed lines never reach the protocol stream: they go to the server’s standard error (section 3) and are journaled, as every print is.

The manifest holds environment variable names, never secret values, so it can be kept in version control.

3. Protocol

Version 1 speaks stdio: newline-delimited JSON-RPC 2.0 messages, one per line, with no embedded newlines. The server writes only MCP messages to stdout and its own log lines to stderr, and it exits when stdin closes.

The stream is protected at the level of file descriptors, not by care alone. Before the server reads its manifest, it duplicates descriptors 0 and 1 to private ones used only by the protocol reader and writer, then points descriptor 1 at standard error and descriptor 0 at /dev/null. Anything else in the process that writes to standard output (a Staal print that somehow reached it, a panic message, a C library called through cap ffi) lands on standard error, and nothing else can read the client’s messages. The protocol writer serializes each message, takes the output lock, writes the whole line (retrying short writes and interrupted calls until every byte is written or a write fails for good), flushes, and only then releases the lock, so concurrent answers never interleave. A write that fails for good means the client is gone: the server stops reading, lets running runs finish or stop as usual, and exits. A test runs a tool whose program prints and whose C library writes to standard output, and checks every line the client receives is a valid MCP message.

Limits on what crosses the stream: an incoming line longer than 4 MiB, or nested deeper than 64 levels, is refused with -32700 (or -32600) without being parsed further. A tool result whose JSON is longer than 1 MiB is not sent: the call answers isError: true saying the result was too large and naming the run id, and the result stays in the journal, where staal_run_status and the operator can reach it.

A line that holds a JSON array is a JSON-RPC batch, which MCP has not allowed since 2025-06-18; it is refused with -32600 and nothing in it is run.

It supports two protocol versions:

Each version has its own request handler; they share the code that lists tools and runs calls, not the code that reads and writes messages, so a field one version requires never leaks into the other.

Which version a request is served under follows from the request, as the 2026-07-28 specification has a dual-era server do: one carrying _meta["io.modelcontextprotocol/protocolVersion"] is served as 2026-07-28; once a client has sent initialize, one without it is served as 2025-11-25; before that, one without it is refused with -32602. Both can be served on the same connection, and the official Python and TypeScript SDKs connect in either mode and with their own probe (section 9). The server keeps no state between requests except the ledger and the journals, which are on disk.

Errors are kept apart by what went wrong. A line that is not JSON is -32700; a message that is not a valid JSON-RPC request is -32600; an unknown method is -32601; invalid parameters for a known method (an unknown tool name, a missing _meta field) are -32602. Everything about running a tool, including arguments the program’s types refuse, is a tool result with isError: true (section 5), so the model sees it. Notifications the server does not use are ignored.

Cancellation. notifications/cancelled for a call whose run has not started (it is waiting for a concurrency slot, or its arguments are being checked) stops it before anything is reserved or run. Once its run has started, cancellation cannot stop it: stopping a run between operations is possible, but the operation in progress would be cut in half and left in doubt. So a cancelled call’s run carries on to its end, and the server, as both protocol versions require, sends nothing more for that request. The client is never told the call was cancelled when it may have acted: the run’s outcome is in its journal and in staal_run_status, under the run id that a notifications/progress message, if the client asked for progress, gave at the start, or in the server’s log.

How long a call can take

A run cannot hang for ever, but it can take long. Every operation that waits is bounded by its own timeout:

Operation Bounded by
HTTP request net.connect_timeout_ms and net.timeout_ms
Model call the model’s timeout_ms, times retries + 1 attempts, plus the waits between them (at most 60 s each)
TCP and TLS exchange net.connect_timeout_ms and net.timeout_ms
PostgreSQL net.connect_timeout_ms and net.timeout_ms, per connection and per statement
SQLite at most 5 s waiting for a locked database; a statement itself has no time limit, so a tool’s queries should be ones the operator knows are quick
Computation max_steps
Listening refused in a tool (section 2)

The defaults suit a script, not a tool call, so in a tool they are required: the manifest must give net.connect_timeout_ms and net.timeout_ms and, for a tool that can reach a model, the model’s timeout_ms and retries, or the server refuses to start. A provider that never answers is a timeout like any other.

On top of those, a tool may set deadline (for example "deadline": "120s"), counted from when the runtime begins executing the run, not from when the request arrived. It bounds when a new action or model call may start, not when the run ends: past the deadline, the run fails as deadline instead of starting the next one, so a deadline never cuts an action in half and never leaves one in doubt. Because every operation is bounded (the table above), a run takes at most its deadline plus its longest single operation; the run does not end exactly at the deadline, and the public documentation says so. A resumed run makes the same decision: an operation already in the journal is replayed, and a run the journal records as ended at its deadline ends there again. The tool’s deadline sets the run’s limits.deadline_ms, which is built and tested on both engines (RUNTIME.md section 4, tests/deadline_test.rs).

A client that sent a progressToken gets one notifications/progress when the run starts, whose message carries its run id. Version 1 sends nothing per operation: the runtime has no hook for it yet (DEFERRED.md, “Progress per operation”).

4. Listing tools

tools/list returns every tool in the manifest’s order, which is deterministic, followed by the server’s own read-only staal_run_status (section 7). A manifest tool may not use that name.

staalc schema is built and tested (tests/interface_test.rs). Its schemas agree with Staal’s own check of inputs on every case tested, with one exception the schema cannot express: a money amount too large to hold exactly is refused by the runtime and accepted by the schema. Staal’s check of the arguments (section 5) is the one that decides, so the gap can only refuse a call, never let a wrong one through.

5. Calling a tool

For tools/call with a tool name and arguments:

  1. Find the tool. An unknown name is a protocol error (-32602).
  2. Check the arguments by Staal’s own input rules, without running anything. A refusal is a tool result with isError: true, so the model can correct them.
  3. Find an earlier call, for a tool with once. If the ledger already holds this call’s key, nothing new runs; the call is answered from the existing run (below).
  4. Take a concurrency slot. If the server already has server.max_concurrent_runs runs in progress, or the tool its own max_concurrent_runs, the call answers isError: true saying the server is busy and to try again later. Nothing is reserved and nothing runs. Calls are not queued: a queue would be one more thing to make durable and recover.
  5. Reserve (section 6). A call over the quota answers isError: true, naming the quota and when it frees up. Nothing runs.
  6. Run it with a new journal at <journals>/<tool>/<first two characters of the run id>/<run id>.journal, where the run id is 32 random hexadecimal characters, and with inputs = the arguments. The arguments are only ever inputs: they cannot add or change a grant, a secret or a limit.
  7. Settle (section 6), then answer from the run’s outcome:
Outcome MCP result
done structuredContent = {"result": <the result>}; content = one text item holding the result’s JSON; isError: false
failed, kind capability isError: true; the text is the broker’s message, e.g. “‘payments.stripe.refund’ allows at most 50.00 USD per action; this action needs 300.00 USD”
failed, kind validation, action, value, provider, budget or deadline isError: true; the text is the failure’s kind and message
failed, kind internal isError: true; the text says an internal error happened and names the run id; the details go to stderr
stopped isError: true; the text says the action may already have happened, that the run is waiting for an operator, its run id, and not to call the tool again for it; structuredContent = {"status": "waiting_for_operator", "run": "<run id>"} (section 7)
a config or input problem (error) isError: true with the problem, so the model can correct its arguments

A failure is a tool result, not a protocol error, so the model sees it and can try again with different arguments. Messages are the runtime’s own, which never contain a secret’s value (RUNTIME.md section 12). The run’s log is not sent to the client; it stays in the journal directory beside the journal.

One logical operation: once

Without once, every call is a new run. If a client sends the same call again (for example after the server died before it answered), the second call is a new execution: it can act again, and only the quota bounds how far. The quota limits total exposure; it does not prevent duplicates, and nothing here says it does. The server logs a warning at start for every tool that can spend money and has no once.

With once, the operator names which arguments identify one logical operation:

"once": { "by": ["order"], "keep": "30d" }

The call’s key is the SHA-256 of the tool’s name and the named arguments, in Staal’s canonical JSON form (keys sorted, money as written by the runtime). by may name every argument, and must name at least one. The key is written to the ledger in the same reserve event that starts the run, and flushed before the run begins, so from that moment a repeated call finds it. A call whose key is found never starts a run:

The earlier run The repeated call gets
still running isError: true: already in progress, with its run id, to follow with staal_run_status
done the earlier result, exactly as it was answered
failed the earlier failure
waiting for an operator the same stopped result

A repeated key with different arguments (the same order, a different amount) is refused with isError: true: it is a different operation pretending to be the same one. Arguments are compared as the runtime reads them, defaults filled in, so {"amount": "35", "currency": "USD"} and "35.00" are the same. A key is remembered for keep (a whole number and m, h or d; at most server.keep, which the manifest check enforces; server.keep when left out), counted from the run’s end; after that, the same arguments start a new run. A call that never started (refused as busy or over its quota, or released at start) holds no key. Over stdio, one server serves one client, so keys are scoped to the tool; with a remote transport they will be scoped to the authenticated client too.

A repeated call returns the same run, and the same run sends the same idempotency keys to the service, so once and the service’s own deduplication work together: once stops a second run, and the service’s key stops a second request within a run. Neither replaces the other, and a payment grant keeps idempotency_key with or without once.

The server runs at most server.max_concurrent_runs runs at once (and each tool at most its own max_concurrent_runs), so a model sending a hundred calls at once gets a handful of runs and many “busy” answers, not a hundred model requests and open connections. Runs resumed at start are resumed one after another, before anything is served. Calls waiting to be checked are bounded too: with more than four times server.max_concurrent_runs plus 16 in progress, a call is answered busy at once.

Annotations, derived from the tool’s grants:

Clients treat annotations as hints from an untrusted server; they change nothing about what the broker allows, and they never claim more than the broker enforces. Each rule has a test: a tool with no grants, with only reads, with reads and writes, with a narrowed capability, with idempotency_key, and with network reads only.

6. Quotas across calls

Every limit in Staal belongs to one run. Through MCP, each call is a new run with fresh uses and max, so without more, uses: 1 would mean one action per call, and a model calling a tool in a loop would get as many as it asked for. A quota bounds a tool across calls, over a sliding time window:

"quota": { "window": "24h", "calls": 100, "money": { "amount": "500.00", "currency": "USD" } }

Reserve, then settle. Before a run starts, the server reserves one call and the run’s worst-case spend: the sum over its money-limited grants of max, or of each times uses where there is no max. If the reservation would exceed the quota, the call is refused and nothing runs. When the run ends, the reservation is settled to what the run’s broker actually spent, which the run’s result reports per grant. Concurrent calls therefore cannot overspend together: each holds its worst case until it settles.

A run that stops in doubt keeps its whole reservation until it is resolved and resumed, because the action in doubt may have spent money.

The ledger is <journals>/server.ledger, one JSON line per event, each written and flushed with fsync before anything that depends on it happens. It is the authority on which runs exist and what they hold: a run exists from its reserve event, and its journal is named by the run id that event records (the runtime creates the journal with that id, so the idempotency keys a service sees carry it too). Only one server process may use a journal directory: it holds an OS lock on <journals>/server.lock, freed if the process dies, and a second server waits up to five seconds for it (an SDK’s probe, or a restart, may still be letting go) and then refuses to start. A torn last line is discarded; any other damage stops the server, as a damaged journal does.

Events: reserve (run id, tool, time, calls and money held, and the once key and arguments hash if any), settle (run id, money actually charged, the outcome kind), release (run id; a reservation whose run never started). Each run has exactly one reserve and at most one settle or release; recovery checks this, and writing a second one for a run is a bug the server refuses to commit. Compaction, at start and at each daily sweep, keeps every event of a run that is unsettled, whose files are still kept (section 7), that was reserved within its tool’s quota window, or that holds a once key not yet expired, and drops released runs, which never started and hold nothing; it writes the new ledger to a temporary file, flushes it, and renames it over the old one, so a crash during compaction leaves one or the other.

Time. Each event records the wall-clock time, in UTC milliseconds since 1970, so time zones and daylight saving never enter into it. The ledger’s time never goes backwards: an event is stamped with the later of the clock and the last event’s time, and a clock found behind the ledger is logged as a warning. So a clock set back cannot make a window shorter or reorder events. A clock set forwards can free a quota early, and nothing inside one machine can detect that; an operator who needs it guarded should run the server where the clock is kept by NTP and alarms on large steps.

A monotonic clock was considered and refused: it does not survive a restart, and a quota has to, so the ledger would still depend on the wall clock at every start.

What settling reads: a run’s result reports usage: for each capability parameter of the entry function, the actions attempted and the money charged, per currency, counting everything narrowed from it and a resumed run’s replay (RUNTIME.md section 6). It is built, the same on both engines, and tested (tests/deadline_test.rs, tests/capi_test.rs).

7. Crashes and restarts

If the server dies during a call, the client sees the connection close. A client that restarts it may send the call again: for a tool with once, it gets the original run; without, it starts a new one (section 5).

A call moves through these states, each made durable before the next begins:

Step Durable record If the server dies here, at the next start
1. reserve reserve in the ledger there is no journal: the run never started, so it gets a release
2. keep the arguments <run id>.inputs.json beside the journal still no journal: as step 1 (the file is removed)
3. create the journal the journal’s first line the journal is there and empty of operations: the run is resumed, which runs it from the start
4. run the journal’s records the run is resumed, exactly as staalc resume would, from its journal and its kept arguments; it finishes, fails, or stops in doubt
5. waiting record (a stopped run) waiting/<run id>.json the run is resumed again and stops again; the record stays as first written
6. settle settle in the ledger done: the run’s journal records its end, and the ledger its settlement; the arguments file is removed
7. answer none nothing to recover; a repeated call is answered from the ledger and journal

The arguments are kept only while a run may need resuming: a settled run’s file is removed at once, and at every start whatever is left of one. A run’s log goes beside its journal as <run id>.log.jsonl.

At start, before it answers anything, the server goes through the ledger and the journal directory together:

Settling never releases a reservation that should stay: a run that stopped in doubt is not settled at all until it is resolved and resumed to an end.

staalc mcp status <manifest> lists unfinished runs: those waiting for an operator, with what is in doubt and the staalc resolve --journal ... path to resolve each, and any other (in progress, or interrupted and resumed at the next start). It also gives each tool’s quota use. It reads the ledger without locking or changing it, so it can run beside the server. An operator resolves a stopped run with staalc resolve, unchanged, and the next start of the server resumes it.

When a run waits for an operator

A run stops in doubt when an action may or may not have happened and cannot safely be sent again. Only the operator can settle that, from the service’s own records. The MCP caller cannot: it is a model, or a user who has no access to those records, and an answer from it would be a guess at the one point where Staal refuses to guess. So resolution never travels over MCP, in either protocol version. In particular the server never answers with input_required: that asks the client’s user, who is the wrong person, and 2025-11-25 clients do not support it.

What each side is told:

So an operator learns of every stopped run even if the notify command is broken: the record is on disk, staalc mcp status lists it, and stderr has it. The notify command only makes the news faster.

Upgrades. A journal is tied to its program, config and runtime version, so upgrading staalc or changing a tool while runs are unfinished makes those runs refuse to resume. The policy is explicit: before an upgrade or a tool change, the operator runs staalc mcp status and lets every unfinished run end (resolving any that wait). If the server starts and finds an unfinished run it cannot resume for this reason, it does not start: it names each such run, so nothing is silently abandoned with money reserved. Each release’s notes repeat this.

Keeping files

Journals are kept as <journals>/<tool>/<first two characters of the run id>/<run id>.journal, so no directory holds more than about a 256th of a tool’s runs. Journals are the authoritative record of what runs did. The server deletes them only under the retention policy the operator sets: server.keep is how many days a settled run’s journal and log are kept (at least 1, default 90), or "forever". At each start, and once a day while running, the journals and logs of runs settled longer ago than that are deleted, the log first and then the journal, so a failure part-way leaves a journal without a log (harmless, retried at the next sweep) and never a log without its journal. A run that is in progress, unsettled, or holds a once key not yet expired is never deleted, whatever its age. staal_run_status answers expired for a run whose journal is gone but whose ledger events are still kept, and unknown once compaction has removed those too.

8. Security

What the server trusts: the manifest, the programs and libraries it names, and the environment that holds the secrets. What it does not: everything that arrives on stdin.

9. How it is tested

10. Decisions

Grants never come from the call. A tool’s grants are fixed in the manifest, and nothing in a call’s arguments can widen, select or fill in a grant. Templating a grant from arguments (for example only set to the order the model names, checked against an allowlist in the manifest) was considered and refused, for two reasons:

Scoping a run to one order or one customer is still needed, and it has two right places, both of which take the scope from trusted data rather than from the model:

  1. Inside the program, from a trusted lookup. The program reads the order through a read capability the operator granted, and narrows its refund capability to that order’s payment before using it. The narrowing is code, reviewed with the program, checked by the compiler and recorded in the journal. Today a program can narrow only to limits written in the code (GUIDE.md, “Policy from your app”); narrowing to a value the program computes is planned language work, and this is the use case that sets its priority.
  2. In the app, when Staal runs in-process. With the framework adapters, the app computes each call’s grants from what it knows about the request (the signed-in user, the order in front of them), as lib.run already allows.

When the server gains an authenticated remote transport, a third place opens: grants chosen by the authenticated principal (a profile per OAuth client or scope), because an authenticated identity is trusted input and a tool argument is not. That is designed with the remote transport, not before.

Operators are told outside MCP. Section 7 (“When a run waits for an operator”) says how: the caller is told the truth and given a status tool, and the operator gets a durable record, a notify command retried until it succeeds, and staalc mcp status. Resolution stays with staalc resolve.

Duplicates are stopped by once, not by the quota. A quota bounds total exposure over a window; it cannot tell a resent call from a new one. Deduplication by a key the operator chooses from the arguments (section 5) was chosen over a key the client sends, because MCP gives the client no field for one and a model would have to invent it; the arguments that identify an operation (an order, a ticket) are already in the call. The key is written to the ledger with the reservation, before the run begins, so there is no moment when a run exists that a repeated call cannot find.

The ledger is the authority; journals are the evidence. The ledger says which runs exist and what they hold; a run’s journal says what it did. Every crash point has one recovery rule (section 7), and the one case the server cannot explain (a journal with no reservation) stops it rather than being guessed at.

No queue. When the server is at its concurrency limit, a call is refused at once. A queue would be another state to make durable, recover and bound, for a client that can simply try again.