Repository navigation
MCP Proxy
agentrt-proxy puts the runtime's gate in front of MCP servers for a program that is not written in Go. An MCP host is the application that runs a model and gives it tools. The host starts the proxy as one of its MCP servers. The proxy connects as a client to the real servers through the mcp module, which pins their tools and takes each tool's side-effect class from the operator, and it offers the host only those tools. Every tools/call the host makes is handed to a Gate, which records it, checks its arguments against the tool's schema, evaluates the policy, pauses on an approval bound by hash to the exact request when the policy asks, executes the upstream call itself, and records the outcome. The proxy talks to the host over stdin and stdout and opens no network listener.
It lives in the nested module github.com/joeylking/agent-runtime/proxy, first tagged proxy/v0.1.0 with core v0.4.0. proxy/v0.2.0, with core v0.5.0, adds agentrt-proxy recheck and the policy's identity, Config.PolicyID. It is a small, deliberately scoped experiment. The Go library remains the full form of the runtime: the proxy has no model, no loop, and no view of a conversation, so several of the runtime's controls cannot apply. Decision 8 records why it is built the way it is, and docs/proxy.md in the repository is the authoritative account.
This is the proxy's scope statement, which docs/proxy.md and the command's help give first. Read it before anything else on this page.
The proxy governs only calls that go through it. If the host gives the model a shell, file access, or other tools of its own, the model can bypass the proxy, and it can approve its own request with the operator command or by editing the database. So the proxy is for hosts without such tools, or with them disabled or behind the host's own prompts, or inside a sandbox whose only way out is the proxy.
It is for one person who is both the host's user and the operator. It does not separate them.
The upstream servers run as that same user. No server may be able to reach the proxy's configuration, its manifests, or its database: a server that can write them can rewrite the policy or forge an approval. Give a filesystem server a root that does not contain them. The proxy refuses to start a stdio server whose arguments name one of those files or a directory holding one, as a best-effort check. It is a guard against a mistake in the configuration, not a boundary: it cannot see what a server reaches by other means, and it protects neither the proxy's binary nor the host's own configuration, either of which a server that can write them can replace.
It never sees model calls, so it sets no token or cost limits, and it never sees the user's prompt.
Each call is a separate run, so there are no limits or policy over a conversation's history.
The duplicate rule refuses a deliberate identical repeat of a mutating call inside the window, not only a retry.
Results are capped and flattened.
The pin covers what a server presents, not what it does.
What it keeps:
- an approval bound by hash to the exact request, durable across restarts, with policy evaluated again when the request is collected;
- a mutating call whose outcome is unknown, cut off by a crash, timed out, or left without an answer from the server, is not run again, as the identical request, without an operator's approval, and other mutating calls to its tool are refused until an operator resolves it;
- the audit record is committed with each state change;
- tools are pinned and classified by the operator;
- an identical mutating call is not executed twice within the window.
MCP gives a server no notion of a conversation. A host may start one proxy process per session, one per call, or several at once, and it passes no session identifier. So each tool call is its own run, with the id <session>.<suffix>, where session is the name in the configuration and the suffix is 16 random hex characters, and the goal call <tool>.
A run holds the call's one tool step. The proxy registers every upstream tool with the gate as a terminal tool, so a call that succeeds completes its run in the transaction that records the result. A call that the policy denied, whose arguments were invalid, or whose tool returned an error leaves its run running after that step. The proxy then ends it with a fail decision whose origin is operator, because the proxy made that decision on the operator's configuration and the model did not. The tool call itself is recorded with origin model (see The Gate). Every run's limits are fixed: three steps, three consecutive failures, a loop threshold of three, and the configured approval and grant expiries.
Because no run spans calls, several requests can wait for approval at once, a rejection ends only the request it rejected, calls with different arguments run in parallel, and two proxy processes on one session name work together (TestProcess_TwoProxiesOnOneSession). The cost is real: the runtime's step, failure, loop, and time limits can say nothing about a conversation, and no policy can see what the model did before. The rules that do span calls are the proxy's own, kept in an index of its calls in the same database: a table of its own, proxy_calls, under migrations of its own in proxy_schema_migrations. The runtime's tables are not written by it, so agentrt reads the file as before.
For agentrt runs this means one row per call that started something new. A call that collects an approved request, or is refused by one of the rules below, adds no row. A run ends COMPLETED, FAILED, or CANCELLED, or waits in WAITING_FOR_APPROVAL. It stays RUNNING only while a call executes it, or when the process running it stopped: then the next identical call ends it or, if it was cut off while executing a mutating call, pauses it for an operator (TestProxy_AbandonedCallIsEndedAndRunFresh). A run's events follow the gate's order (TestProxy_RunsRecordWhatHappened).
The proxy keys each call by the SHA-256 of the registered tool name and agentrt.CanonicalJSON of the arguments, the form the runtime hashes an approval over. Object key order, whitespace, and how a string's characters are escaped do not matter. Every string's contents and every number's literal do, so 1250.5 and 1250.50, or 10 and 1e1, are different requests, as they are different approvals. Arguments the runtime refuses as JSON where it enters, a repeated key, invalid UTF-8, an unpaired surrogate escape, nesting past 256 levels, or a number past its bound, have no key: no rule applies to them, they never match another request, and the gate records them as INVALID (TestRequestKey_NoCollisions, TestProxy_ArgumentsTheRuntimeRefusesNeverCollect). What executes is always the recorded request.
Before starting anything, the proxy looks at the runs of this session with the same key and applies, in order:
-
A run waiting on an approval for this request. The call collects it. While the approval is pending the call is held, then answers
PENDING_APPROVAL. Once it is approved, the gate attaches to the run, evaluates the policy again, judges the grant's age, and executes the recorded request, never this call's bytes. -
A run executing under a live lease. Another call, in this process or another, is executing it, or a process that crashed has not yet lost its lease. Nothing runs, and the call answers
IN_PROGRESS. -
A run left running by a call that is gone, its lease expired. The gate takes it over. A tool that is not read-only, cut off while executing, pauses on an approval of kind
interrupted_side_effectand the call answersINTERRUPTED. A read cut off, a call cut off before its tool ran, or one cut off after its outcome was recorded leaves nothing to execute, and the proxy ends that run. A mutating call that made no attempt, abandoned after the policy allowed it or its lease lost before it executed, does not block the tool (TestUnknown_CutOffBeforeExecutingIsNoAttempt). -
A request rejected, cancelled, or expired within the repeat window. The call answers
REJECTED, with the operator's note, orEXPIRED, and no new approval is asked for within the window. This holds for the identical request only: a model that changes one character of the arguments makes a new request, which asks again, andmax_pendingbounds how many such requests wait at once, not how often a model asks. -
The duplicate rule. For a tool that is not read-only, a run with this key whose tool call executed with a known outcome within
repeat_windowof now refuses the call asDUPLICATE, with the earlier call's time and outcome. A call whose server answered with content the runtime refused executed, and counts. A denied or invalid earlier attempt does not count, because nothing ran. -
An unknown outcome. For a tool that is not read-only, when the last attempt at this request, at any age, was cut off by a crash, timed out, or failed with no answer from the server, and no attempt has executed with a known outcome since, the new run asks an operator with an
interrupted_side_effectapproval naming that attempt, whatever the policy says short ofdeny. The call answersUNKNOWN_OUTCOMEorINTERRUPTED. An operator's rejection does not resolve it: the identical call asks again and never runs onallowalone (TestUnknown_RejectionUnblocksAndAnIdenticalCallAsksAgain). - Otherwise a new run begins and the call is proposed.
Two more rules apply before a new run begins. While another request to the same tool has an unknown outcome that no operator has resolved, every new call to that tool that is not read-only is refused as BLOCKED, naming the approval that asks about it, so a model cannot get around an unknown outcome by changing an argument. And a call that needs an approval while max_pending approvals of the session are already waiting is refused as BLOCKED without asking.
An unknown outcome is resolved, and the tool unblocked, when an operator approves the re-run and the identical call collects it with a known outcome, or when an operator rejects the re-run or cancels its run. It is never resolved by an expiry. An approval about it that expires unanswered, a re-run that meets the run's step limit, or a run that stopped before the proxy asked leaves nothing waiting, so the next mutating call to that tool, or the identical call, asks the same question again in a fresh run. The tool is never blocked with nothing for an operator to act on (TestUnknown_StepLimitKeepsTheToolBlocked, TestUnknown_CrashBeforeTheReRunKeepsTheToolBlocked, TestUnknown_ExpiryAsksAgainRatherThanUnblocks).
The duplicate rule is what makes a host's blind retry after a lost response safe: the retry does not execute a second payment, and it hands the model the first one's outcome. It also refuses a repeat the model meant, inside the window. To repeat a call deliberately, wait out the window, or turn the rule off for that tool with "repeat_window": "0s" in its rule, or for every tool with the top-level repeat_window. Turning it off also turns off rule 4 for that tool. Rule 6 does not depend on the window.
Within one process a call's reading of the index and the runs, and whatever it begins or collects, happens under one lock, while executing and holding happen outside it. Across processes the index holds at most one open row per key, enforced by a unique index, and a new row is claimed in one SQLite transaction. A row is written before its run begins, so a crash between the two leaves a row without a run, which means nothing started: what a call did is always read from its run and steps, never from the index (TestIndex_RowWithoutARunIsBegunUnderItsID, TestIndex_OneCallBeginsARow). The max_pending cap is exact within one process. Two processes that begin approval-requiring calls at the same moment can together go past it by one each.
As the model experiences it, with hold at its default of 15 seconds:
-
The model calls
fs_write_file. The policy requires an approval, so the call is held, and if the request carried a progress token the host is sent a progress notification about once a second. -
If the operator approves within the hold, the call executes and returns the result, in the same call (
TestProxy_ApprovedWithinTheHoldCompletesInOneCall). -
Otherwise the call returns a normal result, not an error:
PENDING_APPROVAL: this request is waiting for an operator's approval (approval 4a49…). Nothing has been executed. Do not call this tool again until you have been told it was approved, do not try another tool to achieve the same thing, and do not ask the user to approve it: the operator decides elsewhere. Tell the user it is waiting. After approval, call this tool again with exactly the same arguments to run it and get the result.
-
Told it was approved, the model calls
fs_write_fileagain with the same arguments, and the proxy executes the recorded request once and returns the result (TestProxy_ApprovalRoundTrip). A third identical call within the window isDUPLICATEand runs nothing.
The wording follows a measurement with two small local models over 96 runs, recorded in decision 8. After a prose pending result the models stopped and reported, did not switch tools, and did not claim success. Told the request was approved, they re-called the same tool with identical arguments in 48 of 48 runs. Merely asked for an update, they did not re-call: they looked for a status tool and passed the approval id. A JSON-shaped pending result made them poll and ask the user to approve. The measurement is small and of two local models. A host or model that behaves otherwise is held by the gate all the same, because collecting executes the recorded request and nothing else.
As the operator experiences it, the proxy writes to its stderr, which a host keeps in its log:
agentrt-proxy: approval d7ce…5f35 (local_mutation) for fs_write_file waits for an operator: run laptop.0b41…e5bd
agentrt-proxy: decide with: agentrt -db "/home/me/proxy/proxy.db" approve laptop.0b41…e5bd -approval d7ce…5f35 (or reject)
agentrt runs lists the waiting run and agentrt show <run> shows the request. agentrt approve prints the approval, the server, the upstream tool, the arguments, the operator's fixed values if the tool has any, and the policy's reason, and asks. reject takes a -note, which the model is shown. The decision is bound to the hash of what was printed, as for any run, and the webhook approver works on the same database too. Approval is decided only outside the host: the proxy offers no tool that approves, and it uses neither MCP elicitation nor sampling toward the host. Nothing the model sees names the operator command, the database, the configuration, or a run id (TestProxy_ModelVisibleTextNamesNoOperatorPath).
An approval's capability, which its hash binds, is the server, the upstream tool, the registered name, the side-effect class, the arguments, and the operator's fixed values for that tool, which the request does not carry. A fixed value changed in the configuration between the approval and the collection makes the policy ask for a new approval instead of executing under a value nobody approved (TestProxy_ChangedFixedValueAsksAgain). The approval's expiry, approval_ttl, and the grant's, grant_ttl, are the runtime's Limits.ApprovalTTL and Limits.GrantTTL, 24 hours each by default. Either way an expired request executes nothing and the call answers EXPIRED (TestProxy_ExpiredIsNotAskedAgain, TestProxy_StaleGrantExecutesNothing).
The proxy serves one tool of its own, gate_status, with an optional approval_id argument. It answers from the database. Without an id it lists the session's pending approvals, each with its id, tool, a summary of its arguments, and how long it has waited. With an id from the last hour it answers for that one: pending; approved and awaiting collection, with the instruction to call the original tool again with exactly the same arguments; approved and executed, only when its step executed, with what its step recorded; approved but not executed, with why; rejected, with the note; or expired. It never executes, decides, or writes anything, and it is not recorded as a run (TestProxy_StatusToolReportsAndNeverExecutes, TestProxy_StatusSaysWhetherAnApprovedRequestExecuted). Its name is gate_status unless status_tool names another, and the proxy refuses to start if an upstream tool registers under the same name (TestProxy_StatusToolNameClashRefused).
A result is what the gate recorded, through the mcp module's mapping: the server's structured content when it sent some and it fits, else its text blocks joined, the whole capped at 64 KiB. An upstream tool's error is an isError result carrying the error text the gate recorded, not a protocol error (TestProxy_UpstreamErrorAndStructuredResult). Every other answer is prose that starts with a fixed prefix, so a host or a test can recognise it:
| Prefix | isError | Meaning |
|---|---|---|
PENDING_APPROVAL |
no | waiting for an operator; nothing executed |
IN_PROGRESS |
no | the same request is executing, or its outcome is not yet known; retry shortly |
INTERRUPTED |
no | an earlier attempt was cut off and its outcome is unknown; an operator decides whether it runs again |
UNKNOWN_OUTCOME |
no | this call, or an earlier attempt of it, timed out or failed with no answer from the server, so it may have taken effect; an operator decides whether it runs again |
UNRECORDED |
no | the server answered this mutating call, so it executed, but its answer could not be recorded; it may have taken effect, and it is not to be retried |
DENIED |
yes | the policy does not allow it, with the policy's reason |
INVALID |
yes | the arguments do not match the schema, with the runtime's reason |
REJECTED |
yes | an operator rejected or cancelled this request, with the note |
EXPIRED |
yes | the approval expired before it was used |
DUPLICATE |
yes | the identical call already executed within the window, with its time and outcome |
BLOCKED |
yes | another request to this tool has an unknown outcome that an operator has not resolved, or too many approvals are waiting |
UNAVAILABLE |
yes | the proxy could not read or write its records; says whether anything was executed |
GATE_STATUS |
no | the status tool's answer |
Text a model, a server, or an operator chose is escaped with trace.Sanitize and bounded before it is put in these answers: a denial's reason, a schema error, an operator's note, a duplicate's quoted result or error, and the status tool's summaries (TestProxy_ServerTextInProseIsEscaped). A result returned as the call's result is not prose and is passed back as recorded.
A call to a tool that is not read-only that times out, or fails without an answer from the server, a dropped connection or a protocol error, may have taken effect. The proxy treats it as an unknown outcome, except when the server's answer or a refusal before anything was sent is known (TestUnknown_ClassifiesRecordedErrors). For an unknown outcome the run is not ended. In the same run the proxy proposes the recorded request again, with origin operator, and the policy asks an operator with an interrupted_side_effect approval whose capability names the earlier attempt. The call answers UNKNOWN_OUTCOME. While it waits, the identical call answers the same and runs nothing, and other mutating calls to the tool are BLOCKED. Approved, the identical call runs it once more. Rejected, nothing runs and the tool is unblocked (TestUnknown_TimeoutAfterTheEffectWaitsForAnOperator). An error the server returned is a known failure: no operator is asked, the identical call within the window is DUPLICATE with the error, and after the window it runs again (TestUnknown_ServerErrorIsAKnownFailure).
Content the runtime refused, not usable JSON, nested past 256 levels, or a number past its bound, is an answer. The server answered, so for a tool that is not read-only the call executed and only its result could not be recorded. The call answers UNRECORDED and counts as executed (TestUnknown_UnrecordedResultCountsAsExecuted).
When the host cancels a call, or times out, while its upstream call is running, the upstream call is not cancelled. It finishes within the tool's timeout, its outcome is recorded, and an identical call afterwards gets it as DUPLICATE (TestProxy_HostCancelLetsTheCallFinish). When stdin closes, or on SIGINT or SIGTERM, the proxy accepts no more calls, lets the calls in flight finish and be recorded, closes the upstream connections, and exits (TestProcess_StdinCloseMidCallFinishesTheStep).
An approval is a row in the database, so a proxy started after the one that asked for it collects it like any other (TestProcess_RestartCollectsAnApprovalGrantedWhileItWasGone). A proxy killed while an upstream call runs leaves that call's run running under a lease that is no longer renewed. Until the lease expires, lease_ttl after its last renewal and 30 seconds by default, an identical call answers IN_PROGRESS. After it expires the next identical call takes the run over, and for a tool that is not read-only the run waits on an interrupted_side_effect approval and the call answers INTERRUPTED. If the operator approves, the next identical call runs the request again, once. If the operator rejects, nothing runs and the tool is no longer blocked (TestProcess_CrashThenApproveRunsItAgainOnce, TestProcess_CrashThenRejectRunsNothingAndUnblocks).
One JSON file, given with -config. It is read only when the current user owns it and its group and others cannot write it, and so are the manifests. The database is checked the same way before the store opens it, when it exists, and again after, and a database path that is a symbolic link is refused. Unknown fields are refused anywhere in the file, and so is a repeated key. Relative paths are relative to the file's own directory.
| Field | Default | Meaning |
|---|---|---|
database |
required | the SQLite file of the runs, approvals, and the proxy's index |
session |
required | 1 to 64 letters, digits, _, or -; the first part of every run id |
servers |
required | the upstream servers |
policy |
agentrt.DefaultPolicy |
side-effect class to allow, require_approval, or deny; a class left out keeps the default |
hold |
15s |
how long a call is held for its approval; 0s answers at once |
repeat_window |
10m |
the window of rules 4 and 5; 0s turns both off |
max_pending |
5 |
approvals the session may have waiting |
approval_ttl |
24h |
how long an approval may stay pending; 0s is no expiry |
grant_ttl |
24h |
how long a grant may wait to be collected; 0s is no expiry |
status_tool |
gate_status |
the status tool's name |
lease_ttl |
30s |
the lease a call holds on its run while it executes; at least 3s
|
Each server takes the mcp.Server fields and its pin and rules: name (tools are offered as <name>_<tool> unless a rule renames them), command for a stdio server or endpoint for a streamable HTTP server, env and inherit_env, connect_timeout, default_timeout, manifest, rules, and skip_path_check. A rule is the shape examples/mcp/rules.json uses (side_effect, timeout, description, deny, fixed, rename, allow_hint_mismatch) plus two overrides for that one tool: outcome, which replaces its class's policy outcome, and repeat_window. There is no condition on arguments and no rule over history: the Go library is the way to write either.
An example against the filesystem server that examples/mcp pins: reads allowed, a write needing an approval. The server's root, /home/me/sandbox, holds none of the proxy's files, which are all in ~/proxy, mode 0600 for the configuration.
{
"database": "proxy.db",
"session": "laptop",
"policy": {"local_mutation": "require_approval"},
"servers": [
{
"name": "fs",
"command": ["node", "/path/to/agent-runtime/examples/mcp/node_modules/@modelcontextprotocol/server-filesystem/dist/index.js", "/home/me/sandbox"],
"connect_timeout": "30s",
"default_timeout": "5s",
"manifest": "fs.manifest.json",
"rules": {
"read_text_file": {"side_effect": "read_only", "timeout": "5s"},
"list_directory": {"side_effect": "read_only", "timeout": "5s"},
"write_file": {"side_effect": "local_mutation", "timeout": "10s", "allow_hint_mismatch": true}
}
}
]
}The server marks write_file destructive and the operator classifies it as a local mutation, which allow_hint_mismatch records. Then:
agentrt-proxy pin -config ~/proxy/proxy.json # writes fs.manifest.json, 0600, and prints the server's hints
agentrt-proxy check -config ~/proxy/proxy.json # what is offered, refused, and unclassifiedpin prints each tool with the annotations the server asserts, which classify nothing. check loads everything as serving would and prints the report. It exits non-zero when a server registers nothing (TestCLI_PinAndCheck). The proxy builds standalone against the versions its go.mod pins, with no workspace; from a clone of the repository:
(cd proxy && go build -o ~/bin/agentrt-proxy ./cmd/agentrt-proxy)A host that starts stdio servers takes the same shape, a command and its arguments:
{
"mcpServers": {
"gated": {
"command": "/home/me/bin/agentrt-proxy",
"args": ["-config", "/home/me/proxy/proxy.json"]
}
}
}The host then lists fs_list_directory, fs_read_text_file, fs_write_file, and gate_status. Give it no shell or file tool of its own, or keep those behind the host's own prompts.
A stdio server whose command arguments name the configuration file, a manifest, or the database, or a directory holding one, is refused at startup, in the spellings the check reads: the argument itself, the part after its first = or :, a file:// URL's path, the rest of a short flag such as -r/path, and a leading ~. Set skip_path_check only for a server that cannot write there (TestConfig_RefusesAServerThatReachesTheProxysFiles, TestConfig_ReachCheckReadsCommonSpellings).
Since proxy/v0.2.0:
agentrt-proxy recheck -config proxy.json [-run ID | -session NAME] [-limit N] [-current-tools] [-json]recheck builds the policy the configuration gives the proxy now and hands it every decision the proxy's policy recorded for a call, rebuilt as it was handed then (agentrt.Recheck, see Re-checking a Run). It prints, run by run, which decisions it would now make otherwise: a call whose payment is now denied, or whose approval would now be another. It re-checks one run with -run, or the session's newest runs, the configuration's session unless -session names another, at most -limit (default 50), read from the proxy's index of its calls. It opens the database read-only and writes nothing.
It connects to no server unless -current-tools, which loads the pinned tools as serving does, the reach check and the pin included, and hands each request its tool's spec as loaded now, so a description reworded since makes each approval asked for that tool another approval (TestRecheck_CurrentToolsAfterADescriptionChange). Without it each request carries the spec it recorded. -json prints the reports as one JSON array.
What it covers and what it does not. The text output ends with a summary of how many runs and decisions were re-checked, how many decisions were not re-checkable and skipped, and how many runs differ, and a line saying what the re-check covers: the gate's policy, which is the policy map, each rule's outcome, its fixed values, and its rename. It does not cover the rules applied before the policy: the duplicate rule, the repeat window, max_pending, the refusal of a rejected or expired approval, or any call refused before the gate. Without -current-tools it covers no change to a tool's schema or denied parameters either.
It exits 0 when every run is the same and at least one decision was re-checked. It exits 1 when any differs, when no run was found (a mistyped -session finds none), when no decision could be re-checked (every one recorded before v0.5.0, or met a policy error), or on an error, and 2 on a usage error (TestCLI_Recheck, TestCLI_RecheckOfNothingFails).
The policy's identity. Every decision the proxy's policy makes records agentrt-proxy/sha256: and the hex SHA-256 of the canonical JSON of the parts of the configuration the policy decides from: the policy map with agentrt.DefaultPolicy's outcomes under it and, for each server by name, each rule's side effect, outcome, fixed values, and rename, keyed by the server's tool name (Config.PolicyID). Nothing else enters it: the database, the session, commands and endpoints, timeouts, descriptions, denied parameters, hold, repeat_window, max_pending, and the TTLs change no decision of the policy, so changing them keeps the identity (TestConfig_PolicyIDCoversWhatThePolicyDecidesFrom). A report says "policy unchanged" when every decision recorded the identity the configuration has now, "policy changed" when one recorded another, and "policy identity unknown" for a decision recorded before the proxy had one.
One input a re-check reads from elsewhere. Whether a request re-runs an attempt whose outcome is unknown is named by the proxy in memory as it proposes the re-run. A re-check reads it from the interruption approval the policy asked for then, so the same configuration re-checks such a call the same (TestRecheck_ReRunOfAnUnknownOutcomeIsTheSame). A re-run the policy denied left no approval, and a re-check under a configuration that no longer denies it answers as it would for a fresh request.
proxy/example scripts a host against a fake payment server bundled with the proxy, and needs no model, no key, and no Node:
go run ./proxy/exampleIt shows a read that runs. It shows a payment that waits, is approved from a second process, and is collected once. It kills the proxy while a payment is inside the server, after which the re-call reports the outcome unknown and waits for the operator, who rejects it. And it shows a host that lost a response and retries, which pays once through the proxy and twice straight against the server (TestDemo_RunsEveryStep).
Besides the scope at the top of this page:
- The proxy reads the upstream servers' tools once, when it starts.
tools/list_changedis ignored, so a server's new or changed tools are seen after a restart, and only afterpinand the rules agree with them. -
agentrt-proxy recheckdoes not re-check the rules applied before the policy, and the chain behind it proves integrity only against a head kept where the database's writers cannot reach; see Re-checking a Run. - The
max_pendingcap can be passed by one per process when several processes ask at the same moment. - An operator who stays away does not unblock a tool: when the approval about its unknown outcome expires, the next mutating call to the tool, or the identical call, asks the same question again in a fresh run. Until an operator approves or rejects, every other mutating call to that tool is
BLOCKED. - "Not run again without an operator's approval" holds for the identical request, keyed as above. After a rejection or an expiry, a request spelled differently,
7for7.0or with an optional field added, is a different request: it is blocked while the unknown outcome is unresolved, and after an operator rejected the re-run it runs under the ordinary policy, which may allow it. - The reach check of a server's arguments is a guard against a mistake, not a boundary.
- The rules across calls key a request by its registered tool name. Two server entries pointing at the same upstream, or a tool renamed between restarts, are different tools to them, so
BLOCKEDand the duplicate rule do not carry from one name to the other. - The status tool answers for an approval from the last hour, from at most 200 calls.
- An approval id is shown to the model, so that it can ask about it. The operator command needs the run id too, which the model is never shown.
- An upstream error's text names the server and the upstream tool.
- In one place the proxy reads the runtime's
runstable directly, by primary key, to settle a row whose run has not begun only if it still has not. - If the database fails under a call, the proxy answers
UNAVAILABLEand the call's run may be left running with no lease. The next identical call ends it.
The Gate is what the proxy is built on. MCP Tools is the module that pins and classifies the upstream servers' tools. Security Model restates the proxy's trust statements by attacker, and Approval Channels is the other way to decide an approval. Design Decisions has decision 8, and Re-checking a Run the check recheck is built on.
Using it
- Getting Started
- Upgrading to v0.5
- Upgrading to v0.4
- Upgrading to v0.3
- Concepts
- Writing an Agent
- The Gate
- Writing Tools
- MCP Tools
- MCP Proxy
- Writing a Policy
- Models and Replay
- Provider Adapters
- Rendering Model Context
- Approvals and Resume
- Interruption and Reconciliation
- Run Lease
- Limits
- Testing a Consumer
- Re-checking a Run
- Measuring Agents
Inside it
- Internals
- Persistence and Inspection
- Performance
- The Operator Command
- Approval Channels
- Exporting Events
- Testing
Deciding on it
- Safety Properties
- Security Model
- Comparison with Frameworks
- Status and Roadmap
- Roadmap
- Design Decisions
- Essays
- Contributing
- Releasing
Reference