MCP server exposing the OZ policy-synth core (record_transaction + synthesize_policy) over stdio and Streamable HTTP transports.
Strong domain-specific tool design with clear naming (verb_noun pattern: record_, synthesize_, simulate_, verify_, declare_, install_, revoke_, get_). All 8 tools have detailed descriptions (avg 250+ chars) explaining WHAT, WHEN, and WHY. Input schemas present with types and enums for network/source. However, output schemas are NOT documented in the provided code, LLMs cannot see what fields to expect from responses. Parameter descriptions are thorough but some lack explicit format/range constraints. Error handling mentions 'ToolError' but recovery guidance is sparse. No tool annotations (readOnlyHint/destructiveHint) despite clear risk classifications (READ_ONLY vs WRITE).
Build an interpreter predicate from a DECLARED constraint - the method to pin, and optionally the contract, a per-call amount cap, a recipient allowlist, and a minimum-output ratio (a swap slippage floor, bounding the output argument against the call's own input). Use this when there is no transaction to record, or when `record_transaction` refuses a contract it does not recognise. Returns the predicate tree, its canonical encoding and hash, ready for `install_policy`. `warnings` names any argument index that was GUESSED rather than supplied - a bound on the wrong argument constrains nothing while looking correct, so read them. There is no rolling spend window and no approval threshold: neither is expressible in grammar 4, and both belong to the OpenZeppelin account layer.
Read-only fingerprint lookup for the policy interpreter contract: returns the pinned address, grammar version, and wasm sha256 (from the pinned constants + SELF_VERSION). When `verifyLive=true`, performs an additional `grammar_version()` RPC call against the pinned address and reports whether the deployed contract matches the pin - a live mismatch check is more useful than a fabricated audit field.
Build an UNSIGNED Soroban transaction XDR for `account.add_context_rule(...)` that installs a new policy rule on the given smart account. Name the rule ONE of three ways. `fromPredicate: { encodedPredicate, signers }` installs a predicate you already hold - the base64 string `declare_policy` returns - and scopes the rule to whatever contract that predicate pins. `fromHash: { transactionHash, signers }` - with `fromHash` the server re-synthesizes the rule from the recording itself, and `signers` (plain G.../C... addresses) names the keys the rule governs. Naming them is required: synthesis reads a transaction and cannot decide which keys a rule binds, and a rule that governs no key is refused on chain. `rule` is the full ContextRuleDraft and is for programmatic callers only - retyping it loses the types of `validUntilLedger`, `signers` and `policies`. In BOTH handle forms `signers` is a list of plain Stellar account addresses (G...): a delegated signer is an account, NOT a deployed signer contract, and nothing needs to be deployed to name one. The response carries an `authorityScan`: every rule already on the account that a signer of this install could name INSTEAD, including an unpoliced one against which the predicate never runs - a predicate only constrains a key when the policed rule is the only rule that key is on. The account is READ over RPC to build it; pass `existingRules` to supply them yourself instead (useful offline). `authorityScan: null` means NOT CHECKED - the read failed, or it could not account for every live rule - and must never be read as "nothing found". The wallet signs the returned XDR - the signature IS the user-confirmation step (this server is stateless and holds no key material, so there is no two-call confirm pair). `installNonce` defaults to 1, which is correct for a fresh rule - do not ask the caller for it. If the recorded call spends an amount and no `limitAmount` is given, the install is REFUSED: such a rule would constrain everything about the call except how much it moves, and it would install and verify cleanly while capping nothing. Supply `fromHash.userResponses.limitAmount`, or `allowUnboundedAmount: true` to do it deliberately. For a ROLLING TOTAL - "at most N per day" - pass `spendingLimit: { amount, periodLedgers }`. It attaches an OpenZeppelin `spending_limit` beside the predicate on the same rule, and both must permit. A predicate alone CANNOT express a rolling total: the interpreter is handed one call and keeps no state, so a per-call cap of N authorises N again on the very next call. The window counts LEDGERS (about five seconds each), and the rule must be scoped to the token contract whose transfers it meters. One transaction is all it takes: the smart account calls the interpreter `install` itself while running `add_context_rule`, so once this XDR is signed and submitted the rule is live and the predicate is enforced. The rule id the account assigned is in the transaction result. Do not tell the caller a second call is outstanding.
Output schemas not documented. LLMs cannot see what fields responses contain (e.g., what does synthesize_policy return? What fields are in ProposedPolicy?). This forces agents to guess structure and risks parsing failures.
No tool annotations (readOnlyHint/destructiveHint/idempotentHint) despite clear risk classifications. install_policy and revoke_policy are WRITE operations that should be marked destructiveHint=true; read-only tools should be marked readOnlyHint=true. This helps agents reason about side effects.
| Scored | Grade | Overall | Spec posture | Rubric |
|---|---|---|---|---|
| 2026-09-23 | C | 63 | 2026-07-28+ | v2 |
Decode a Soroban transaction (on-chain hash OR base64 envelope XDR) into a RecordedTransaction. Returns a machine-readable ToolError on validation failure.
Build an UNSIGNED Soroban transaction XDR for `account.remove_context_rule(ruleId)` that removes a policy rule from the given smart account. The smart account handles uninstalling each attached policy itself. Auth is master-only - the source account MUST be the master signer set; delegated signers cannot uninstall.
Evaluate a predicate against one recorded call and report permit/deny with the deny reason. The evaluator is a second implementation of the on-chain semantics, cross-checked against the Rust interpreter by the conformance harness, so a verdict here is a claim about what the contract would do. Pass `transactionHash` with `smartAccount` and the server records and synthesizes the predicate itself; or pass a predicate together with the call to check it against. For a predicate you already hold, pass `encodedPredicate` (the base64 string `declare_policy` and `synthesize_policy` return) rather than retyping the `predicate` tree.
Synthesize a ProposedPolicy from a recorded transaction (`source: recording`). Pass `transactionHash` and `network` to have the server re-record and synthesize in one step - prefer this over copying a `recordedTx` payload back out of a previous tool result, which loses fields. `recordedTx` remains accepted for programmatic callers holding the object.
Check a predicate against the transaction it was synthesised from, plus a generated deny case per dimension. Reports `ok` only when the permit case is permitted AND every deny case is denied - a denied permit case means the policy is too strict, a permitted deny case means it is too loose. Pass `transactionHash` with `smartAccount` and the server rebuilds the predicate and the recording itself; For a predicate you already hold - a DECLARED one has no recording behind it, so re-synthesizing would check something else - pass `encodedPredicate` (the base64 string) with `transactionHash`. Retyping the `predicate` tree is the error-prone path; do not take it, and never skip this check because the tree was not to hand.
install_policy parameter complexity. 'fromPredicate' and 'fromHash' are mutually exclusive alternatives, but this is not explicitly stated in parameter descriptions. LLMs may pass both or neither, causing ambiguous failures. Document: 'Pass exactly one of: fromPredicate OR fromHash (not both).'
Error handling lacks recovery guidance. Descriptions mention 'ToolError on validation failure' but do not explain what the LLM should do next (retry? ask user? call a different tool?). Pattern:recovery-guide requires actionable error messages.
Parameter format constraints not explicit. 'transactionHash' and 'encodedPredicate' lack format descriptions (e.g., 'base64 string', 'hex hash'). 'smartAccount' lacks validation hint (e.g., 'Stellar account address starting with C...'). LLMs cannot validate inputs without explicit format guidance.