@crediolabs/policy-builder-mcp 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -274,6 +274,7 @@ export declare const SynthesizePolicyToolShape: {
274
274
  }>;
275
275
  sourceAccount: z.ZodString;
276
276
  }, z.ZodTypeAny, "passthrough">>>;
277
+ readonly transactionHash: z.ZodOptional<z.ZodString>;
277
278
  readonly network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
278
279
  readonly userResponses: z.ZodOptional<z.ZodObject<{
279
280
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
@@ -308,8 +309,9 @@ export declare const SynthesizePolicyToolShape: {
308
309
  readonly explain: z.ZodOptional<z.ZodBoolean>;
309
310
  };
310
311
  export declare const SimulatePolicyToolShape: {
311
- readonly predicate: z.ZodType<unknown, z.ZodTypeDef, unknown>;
312
- readonly permitTx: z.ZodObject<{
312
+ readonly predicate: z.ZodOptional<z.ZodType<unknown, z.ZodTypeDef, unknown>>;
313
+ readonly encodedPredicate: z.ZodOptional<z.ZodString>;
314
+ readonly permitTx: z.ZodOptional<z.ZodObject<{
313
315
  network: z.ZodEnum<["mainnet", "testnet"]>;
314
316
  signers: z.ZodArray<z.ZodString, "many">;
315
317
  invocations: z.ZodArray<z.ZodType<unknown, z.ZodTypeDef, unknown>, "many">;
@@ -567,12 +569,29 @@ export declare const SimulatePolicyToolShape: {
567
569
  thresholdUsed: number;
568
570
  }>;
569
571
  sourceAccount: z.ZodString;
570
- }, z.ZodTypeAny, "passthrough">>;
572
+ }, z.ZodTypeAny, "passthrough">>>;
573
+ readonly transactionHash: z.ZodOptional<z.ZodString>;
574
+ readonly network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
575
+ readonly smartAccount: z.ZodOptional<z.ZodString>;
576
+ readonly userResponses: z.ZodOptional<z.ZodObject<{
577
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
578
+ limitAmount: z.ZodOptional<z.ZodString>;
579
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
580
+ }, "strict", z.ZodTypeAny, {
581
+ limitAmount?: string | undefined;
582
+ validUntilLedger?: number | undefined;
583
+ swapRecipientAllowlist?: string[] | undefined;
584
+ }, {
585
+ limitAmount?: string | undefined;
586
+ validUntilLedger?: number | undefined;
587
+ swapRecipientAllowlist?: string[] | undefined;
588
+ }>>;
571
589
  readonly validUntilLedger: z.ZodOptional<z.ZodNumber>;
572
590
  };
573
591
  export declare const VerifyPolicyToolShape: {
574
- readonly predicate: z.ZodType<unknown, z.ZodTypeDef, unknown>;
575
- readonly permitTx: z.ZodObject<{
592
+ readonly predicate: z.ZodOptional<z.ZodType<unknown, z.ZodTypeDef, unknown>>;
593
+ readonly encodedPredicate: z.ZodOptional<z.ZodString>;
594
+ readonly permitTx: z.ZodOptional<z.ZodObject<{
576
595
  network: z.ZodEnum<["mainnet", "testnet"]>;
577
596
  signers: z.ZodArray<z.ZodString, "many">;
578
597
  invocations: z.ZodArray<z.ZodType<unknown, z.ZodTypeDef, unknown>, "many">;
@@ -830,7 +849,23 @@ export declare const VerifyPolicyToolShape: {
830
849
  thresholdUsed: number;
831
850
  }>;
832
851
  sourceAccount: z.ZodString;
833
- }, z.ZodTypeAny, "passthrough">>;
852
+ }, z.ZodTypeAny, "passthrough">>>;
853
+ readonly transactionHash: z.ZodOptional<z.ZodString>;
854
+ readonly network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
855
+ readonly smartAccount: z.ZodOptional<z.ZodString>;
856
+ readonly userResponses: z.ZodOptional<z.ZodObject<{
857
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
858
+ limitAmount: z.ZodOptional<z.ZodString>;
859
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
860
+ }, "strict", z.ZodTypeAny, {
861
+ limitAmount?: string | undefined;
862
+ validUntilLedger?: number | undefined;
863
+ swapRecipientAllowlist?: string[] | undefined;
864
+ }, {
865
+ limitAmount?: string | undefined;
866
+ validUntilLedger?: number | undefined;
867
+ swapRecipientAllowlist?: string[] | undefined;
868
+ }>>;
834
869
  readonly validUntilLedger: z.ZodOptional<z.ZodNumber>;
835
870
  };
836
871
  export type { GetInterpreterInfoInput, InstallPolicyInput, RevokePolicyInput, SimulatePolicyInput, VerifyPolicyInput, } from '@crediolabs/policy-synth/run';
@@ -891,7 +926,67 @@ export declare const InstallPolicyToolShape: {
891
926
  * "not checked" rather than "nothing found". */
892
927
  readonly existingRules: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>;
893
928
  readonly rule: z.ZodOptional<z.ZodUnknown>;
929
+ readonly fromPredicate: z.ZodOptional<z.ZodObject<{
930
+ encodedPredicate: z.ZodString;
931
+ signers: z.ZodArray<z.ZodString, "many">;
932
+ name: z.ZodOptional<z.ZodString>;
933
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
934
+ }, "strip", z.ZodTypeAny, {
935
+ signers: string[];
936
+ encodedPredicate: string;
937
+ name?: string | undefined;
938
+ validUntilLedger?: number | undefined;
939
+ }, {
940
+ signers: string[];
941
+ encodedPredicate: string;
942
+ name?: string | undefined;
943
+ validUntilLedger?: number | undefined;
944
+ }>>;
945
+ readonly fromHash: z.ZodOptional<z.ZodObject<{
946
+ transactionHash: z.ZodString;
947
+ signers: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
948
+ userResponses: z.ZodOptional<z.ZodObject<{
949
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
950
+ limitAmount: z.ZodOptional<z.ZodString>;
951
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
952
+ }, "strict", z.ZodTypeAny, {
953
+ limitAmount?: string | undefined;
954
+ validUntilLedger?: number | undefined;
955
+ swapRecipientAllowlist?: string[] | undefined;
956
+ }, {
957
+ limitAmount?: string | undefined;
958
+ validUntilLedger?: number | undefined;
959
+ swapRecipientAllowlist?: string[] | undefined;
960
+ }>>;
961
+ }, "strip", z.ZodTypeAny, {
962
+ transactionHash: string;
963
+ signers?: string[] | undefined;
964
+ userResponses?: {
965
+ limitAmount?: string | undefined;
966
+ validUntilLedger?: number | undefined;
967
+ swapRecipientAllowlist?: string[] | undefined;
968
+ } | undefined;
969
+ }, {
970
+ transactionHash: string;
971
+ signers?: string[] | undefined;
972
+ userResponses?: {
973
+ limitAmount?: string | undefined;
974
+ validUntilLedger?: number | undefined;
975
+ swapRecipientAllowlist?: string[] | undefined;
976
+ } | undefined;
977
+ }>>;
894
978
  readonly installNonce: z.ZodOptional<z.ZodNumber>;
979
+ readonly spendingLimit: z.ZodOptional<z.ZodObject<{
980
+ amount: z.ZodString;
981
+ periodLedgers: z.ZodNumber;
982
+ }, "strip", z.ZodTypeAny, {
983
+ amount: string;
984
+ periodLedgers: number;
985
+ }, {
986
+ amount: string;
987
+ periodLedgers: number;
988
+ }>>;
989
+ readonly allowUnboundedAmount: z.ZodOptional<z.ZodBoolean>;
895
990
  readonly interpreterAddress: z.ZodOptional<z.ZodString>;
896
991
  readonly smartAccount: z.ZodOptional<z.ZodString>;
897
992
  readonly sourceAccount: z.ZodOptional<z.ZodString>;
@@ -42,7 +42,12 @@ export const RecordTransactionToolShape = {
42
42
  * re-validates against the strict schema. */
43
43
  export const SynthesizePolicyToolShape = {
44
44
  source: z.literal('recording').optional(),
45
+ // Supply EITHER `recordedTx` (the whole recording) OR `transactionHash`. An agent
46
+ // should prefer the hash: it cannot copy a multi-thousand-character recording back out
47
+ // of its own transcript without losing fields, and the server can re-record
48
+ // from the hash deterministically.
45
49
  recordedTx: RecordedTransactionSchema.optional(),
50
+ transactionHash: z.string().optional(),
46
51
  network: NetworkSchema.optional(),
47
52
  userResponses: ComposeUserResponsesSchema.optional(),
48
53
  confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
@@ -60,8 +65,21 @@ export const SynthesizePolicyToolShape = {
60
65
  * (from `synthesize_policy` under `explain`) plus the recording it was
61
66
  * synthesised from. */
62
67
  const PolicyCheckToolShape = {
63
- predicate: PredicateNodeSchema,
64
- permitTx: RecordedTransactionSchema,
68
+ // Supply EITHER `transactionHash` OR both `predicate` and `permitTx`. The tree comes
69
+ // back from `synthesize_policy` only under `explain`, so a caller who did not
70
+ // ask for it has nothing to pass and would skip the check - and this IS the
71
+ // check. `transactionHash` lets the server rebuild both from the transaction.
72
+ predicate: PredicateNodeSchema.optional(),
73
+ // The canonical encoding `declare_policy` and `synthesize_policy` both
74
+ // return. One opaque string, where the tree is what callers mistype.
75
+ encodedPredicate: z.string().optional(),
76
+ permitTx: RecordedTransactionSchema.optional(),
77
+ transactionHash: z.string().optional(),
78
+ network: NetworkSchema.optional(),
79
+ // Needed with `transactionHash`: the predicate is lowered against the account it will
80
+ // be installed on.
81
+ smartAccount: z.string().optional(),
82
+ userResponses: ComposeUserResponsesSchema.optional(),
65
83
  validUntilLedger: z.number().int().positive().optional(),
66
84
  };
67
85
  export const SimulatePolicyToolShape = { ...PolicyCheckToolShape };
@@ -127,8 +145,46 @@ export const InstallPolicyToolShape = {
127
145
  * authority scan; omitting them returns `authorityScan: null`, which means
128
146
  * "not checked" rather than "nothing found". */
129
147
  existingRules: z.array(z.unknown()).optional(),
148
+ // Supply EITHER `rule` (the whole ContextRuleDraft) OR `fromHash`. Same
149
+ // reason as `synthesize_policy`: a draft nests i128 strings, a signer array
150
+ // and a policy array, and an agent retyping it out of its own transcript
151
+ // loses their types. `fromHash` re-derives the draft server-side from the
152
+ // recording, so the install runs against the rule the synthesizer produced.
130
153
  rule: z.unknown().optional(),
154
+ // Install a predicate you already hold - the base64 string `declare_policy`
155
+ // returns - naming the keys it governs as plain account addresses. Without
156
+ // this there is no route from `declare_policy` to here.
157
+ fromPredicate: z
158
+ .object({
159
+ encodedPredicate: z.string(),
160
+ signers: z.array(z.string()),
161
+ name: z.string().optional(),
162
+ validUntilLedger: z.number().int().positive().optional(),
163
+ })
164
+ .optional(),
165
+ fromHash: z
166
+ .object({
167
+ transactionHash: z.string(),
168
+ // The keys the rule governs, as plain Stellar addresses. Synthesis does
169
+ // not choose them, and a rule governing no key is refused on chain.
170
+ signers: z.array(z.string()).optional(),
171
+ userResponses: ComposeUserResponsesSchema.optional(),
172
+ })
173
+ .optional(),
131
174
  installNonce: z.number().int().positive().optional(),
175
+ // A ROLLING TOTAL beside the per-call predicate, as an OpenZeppelin
176
+ // spending_limit on the same rule. Both must permit. This is the only way to
177
+ // express "N per day": the interpreter is handed one call and keeps no state.
178
+ spendingLimit: z
179
+ .object({
180
+ amount: z.string(),
181
+ periodLedgers: z.number().int().positive(),
182
+ })
183
+ .optional(),
184
+ // Opt-in to installing a rule that bounds no amount when the recording showed
185
+ // a spend. Default-deny: such a rule installs and verifies cleanly and caps
186
+ // nothing.
187
+ allowUnboundedAmount: z.boolean().optional(),
132
188
  interpreterAddress: z.string().optional(),
133
189
  };
134
190
  /** Flat ZodRawShape for `revoke_policy`. */
@@ -22,18 +22,22 @@ const toCallToolResult = (res) => mcpResultFromCore(res);
22
22
  /** Build a fresh, stateless MCP server. The caller owns the returned object
23
23
  * and connects it to a single transport (stdio or Streamable HTTP). */
24
24
  export function createMcpServer() {
25
- const server = new McpServer({ name: 'policy-builder-mcp', version: '1.0.0' }, { capabilities: { tools: {} } });
25
+ const server = new McpServer(
26
+ // Kept in step with package.json by `server-version.test.ts`. It was
27
+ // hardcoded once already and reported 0.0.0 to every client; a literal is
28
+ // fine, a literal nothing checks is not.
29
+ { name: 'policy-builder-mcp', version: '1.1.0' }, { capabilities: { tools: {} } });
26
30
  registerTools(server);
27
31
  return server;
28
32
  }
29
33
  /** Idempotent registration of the T1 tool set on the given server. */
30
34
  export function registerTools(server) {
31
35
  server.tool('record_transaction', 'Decode a Soroban transaction (on-chain hash OR base64 envelope XDR) into a RecordedTransaction. Returns a machine-readable ToolError on validation failure.', RecordTransactionToolShape, (args) => runRecordTransaction(args).then(toCallToolResult));
32
- server.tool('synthesize_policy', 'Synthesize a ProposedPolicy from a RecordedTransaction (`source: recording`).', SynthesizePolicyToolShape, (args) => runSynthesizePolicy(args).then(toCallToolResult));
33
- server.tool('simulate_policy', '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 the `predicate` returned by `synthesize_policy` under `explain`.', SimulatePolicyToolShape, (args) => toCallToolResult(runSimulatePolicy(args)));
34
- server.tool('verify_policy', '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.', VerifyPolicyToolShape, (args) => toCallToolResult(runVerifyPolicy(args)));
36
+ server.tool('synthesize_policy', '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.', SynthesizePolicyToolShape, (args) => runSynthesizePolicy(args).then(toCallToolResult));
37
+ server.tool('simulate_policy', '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.', SimulatePolicyToolShape, (args) => runSimulatePolicy(args).then(toCallToolResult));
38
+ server.tool('verify_policy', '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.', VerifyPolicyToolShape, (args) => runVerifyPolicy(args).then(toCallToolResult));
35
39
  server.tool('declare_policy', "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.", DeclarePolicyToolShape, (args) => Promise.resolve(runDeclarePolicy(args)).then(toCallToolResult));
36
- server.tool('install_policy', 'Build an UNSIGNED Soroban transaction XDR for `account.add_context_rule(...)` that installs a new policy rule on the given smart account. 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). Only CALL 1 is emitted; the interpreter `install` follow-up needs the rule id the account assigns in call 1 and is documented in `followUp` in the response.', InstallPolicyToolShape, (args) => runInstallPolicy(args).then(toCallToolResult));
40
+ server.tool('install_policy', '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.', InstallPolicyToolShape, (args) => runInstallPolicy(args).then(toCallToolResult));
37
41
  server.tool('revoke_policy', '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.', RevokePolicyToolShape, (args) => runRevokePolicy(args).then(toCallToolResult));
38
42
  server.tool('get_interpreter_info', '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.', GetInterpreterInfoToolShape, (args) => runGetInterpreterInfo(args).then(toCallToolResult));
39
43
  }
@@ -274,6 +274,7 @@ export declare const SynthesizePolicyToolShape: {
274
274
  }>;
275
275
  sourceAccount: z.ZodString;
276
276
  }, z.ZodTypeAny, "passthrough">>>;
277
+ readonly transactionHash: z.ZodOptional<z.ZodString>;
277
278
  readonly network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
278
279
  readonly userResponses: z.ZodOptional<z.ZodObject<{
279
280
  validUntilLedger: z.ZodOptional<z.ZodNumber>;
@@ -308,8 +309,9 @@ export declare const SynthesizePolicyToolShape: {
308
309
  readonly explain: z.ZodOptional<z.ZodBoolean>;
309
310
  };
310
311
  export declare const SimulatePolicyToolShape: {
311
- readonly predicate: z.ZodType<unknown, z.ZodTypeDef, unknown>;
312
- readonly permitTx: z.ZodObject<{
312
+ readonly predicate: z.ZodOptional<z.ZodType<unknown, z.ZodTypeDef, unknown>>;
313
+ readonly encodedPredicate: z.ZodOptional<z.ZodString>;
314
+ readonly permitTx: z.ZodOptional<z.ZodObject<{
313
315
  network: z.ZodEnum<["mainnet", "testnet"]>;
314
316
  signers: z.ZodArray<z.ZodString, "many">;
315
317
  invocations: z.ZodArray<z.ZodType<unknown, z.ZodTypeDef, unknown>, "many">;
@@ -567,12 +569,29 @@ export declare const SimulatePolicyToolShape: {
567
569
  thresholdUsed: number;
568
570
  }>;
569
571
  sourceAccount: z.ZodString;
570
- }, z.ZodTypeAny, "passthrough">>;
572
+ }, z.ZodTypeAny, "passthrough">>>;
573
+ readonly transactionHash: z.ZodOptional<z.ZodString>;
574
+ readonly network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
575
+ readonly smartAccount: z.ZodOptional<z.ZodString>;
576
+ readonly userResponses: z.ZodOptional<z.ZodObject<{
577
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
578
+ limitAmount: z.ZodOptional<z.ZodString>;
579
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
580
+ }, "strict", z.ZodTypeAny, {
581
+ limitAmount?: string | undefined;
582
+ validUntilLedger?: number | undefined;
583
+ swapRecipientAllowlist?: string[] | undefined;
584
+ }, {
585
+ limitAmount?: string | undefined;
586
+ validUntilLedger?: number | undefined;
587
+ swapRecipientAllowlist?: string[] | undefined;
588
+ }>>;
571
589
  readonly validUntilLedger: z.ZodOptional<z.ZodNumber>;
572
590
  };
573
591
  export declare const VerifyPolicyToolShape: {
574
- readonly predicate: z.ZodType<unknown, z.ZodTypeDef, unknown>;
575
- readonly permitTx: z.ZodObject<{
592
+ readonly predicate: z.ZodOptional<z.ZodType<unknown, z.ZodTypeDef, unknown>>;
593
+ readonly encodedPredicate: z.ZodOptional<z.ZodString>;
594
+ readonly permitTx: z.ZodOptional<z.ZodObject<{
576
595
  network: z.ZodEnum<["mainnet", "testnet"]>;
577
596
  signers: z.ZodArray<z.ZodString, "many">;
578
597
  invocations: z.ZodArray<z.ZodType<unknown, z.ZodTypeDef, unknown>, "many">;
@@ -830,7 +849,23 @@ export declare const VerifyPolicyToolShape: {
830
849
  thresholdUsed: number;
831
850
  }>;
832
851
  sourceAccount: z.ZodString;
833
- }, z.ZodTypeAny, "passthrough">>;
852
+ }, z.ZodTypeAny, "passthrough">>>;
853
+ readonly transactionHash: z.ZodOptional<z.ZodString>;
854
+ readonly network: z.ZodOptional<z.ZodEnum<["mainnet", "testnet"]>>;
855
+ readonly smartAccount: z.ZodOptional<z.ZodString>;
856
+ readonly userResponses: z.ZodOptional<z.ZodObject<{
857
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
858
+ limitAmount: z.ZodOptional<z.ZodString>;
859
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
860
+ }, "strict", z.ZodTypeAny, {
861
+ limitAmount?: string | undefined;
862
+ validUntilLedger?: number | undefined;
863
+ swapRecipientAllowlist?: string[] | undefined;
864
+ }, {
865
+ limitAmount?: string | undefined;
866
+ validUntilLedger?: number | undefined;
867
+ swapRecipientAllowlist?: string[] | undefined;
868
+ }>>;
834
869
  readonly validUntilLedger: z.ZodOptional<z.ZodNumber>;
835
870
  };
836
871
  export type { GetInterpreterInfoInput, InstallPolicyInput, RevokePolicyInput, SimulatePolicyInput, VerifyPolicyInput, } from '@crediolabs/policy-synth/run';
@@ -891,7 +926,67 @@ export declare const InstallPolicyToolShape: {
891
926
  * "not checked" rather than "nothing found". */
892
927
  readonly existingRules: z.ZodOptional<z.ZodArray<z.ZodUnknown, "many">>;
893
928
  readonly rule: z.ZodOptional<z.ZodUnknown>;
929
+ readonly fromPredicate: z.ZodOptional<z.ZodObject<{
930
+ encodedPredicate: z.ZodString;
931
+ signers: z.ZodArray<z.ZodString, "many">;
932
+ name: z.ZodOptional<z.ZodString>;
933
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
934
+ }, "strip", z.ZodTypeAny, {
935
+ signers: string[];
936
+ encodedPredicate: string;
937
+ name?: string | undefined;
938
+ validUntilLedger?: number | undefined;
939
+ }, {
940
+ signers: string[];
941
+ encodedPredicate: string;
942
+ name?: string | undefined;
943
+ validUntilLedger?: number | undefined;
944
+ }>>;
945
+ readonly fromHash: z.ZodOptional<z.ZodObject<{
946
+ transactionHash: z.ZodString;
947
+ signers: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
948
+ userResponses: z.ZodOptional<z.ZodObject<{
949
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
950
+ limitAmount: z.ZodOptional<z.ZodString>;
951
+ swapRecipientAllowlist: z.ZodOptional<z.ZodArray<z.ZodEffects<z.ZodString, string, string>, "many">>;
952
+ }, "strict", z.ZodTypeAny, {
953
+ limitAmount?: string | undefined;
954
+ validUntilLedger?: number | undefined;
955
+ swapRecipientAllowlist?: string[] | undefined;
956
+ }, {
957
+ limitAmount?: string | undefined;
958
+ validUntilLedger?: number | undefined;
959
+ swapRecipientAllowlist?: string[] | undefined;
960
+ }>>;
961
+ }, "strip", z.ZodTypeAny, {
962
+ transactionHash: string;
963
+ signers?: string[] | undefined;
964
+ userResponses?: {
965
+ limitAmount?: string | undefined;
966
+ validUntilLedger?: number | undefined;
967
+ swapRecipientAllowlist?: string[] | undefined;
968
+ } | undefined;
969
+ }, {
970
+ transactionHash: string;
971
+ signers?: string[] | undefined;
972
+ userResponses?: {
973
+ limitAmount?: string | undefined;
974
+ validUntilLedger?: number | undefined;
975
+ swapRecipientAllowlist?: string[] | undefined;
976
+ } | undefined;
977
+ }>>;
894
978
  readonly installNonce: z.ZodOptional<z.ZodNumber>;
979
+ readonly spendingLimit: z.ZodOptional<z.ZodObject<{
980
+ amount: z.ZodString;
981
+ periodLedgers: z.ZodNumber;
982
+ }, "strip", z.ZodTypeAny, {
983
+ amount: string;
984
+ periodLedgers: number;
985
+ }, {
986
+ amount: string;
987
+ periodLedgers: number;
988
+ }>>;
989
+ readonly allowUnboundedAmount: z.ZodOptional<z.ZodBoolean>;
895
990
  readonly interpreterAddress: z.ZodOptional<z.ZodString>;
896
991
  readonly smartAccount: z.ZodOptional<z.ZodString>;
897
992
  readonly sourceAccount: z.ZodOptional<z.ZodString>;
@@ -50,7 +50,12 @@ exports.RecordTransactionToolShape = {
50
50
  * re-validates against the strict schema. */
51
51
  exports.SynthesizePolicyToolShape = {
52
52
  source: zod_1.z.literal('recording').optional(),
53
+ // Supply EITHER `recordedTx` (the whole recording) OR `transactionHash`. An agent
54
+ // should prefer the hash: it cannot copy a multi-thousand-character recording back out
55
+ // of its own transcript without losing fields, and the server can re-record
56
+ // from the hash deterministically.
53
57
  recordedTx: run_1.RecordedTransactionSchema.optional(),
58
+ transactionHash: zod_1.z.string().optional(),
54
59
  network: run_1.NetworkSchema.optional(),
55
60
  userResponses: run_1.ComposeUserResponsesSchema.optional(),
56
61
  confidenceOverride: zod_1.z.object({ threshold: zod_1.z.number().min(0).max(1) }).optional(),
@@ -68,8 +73,21 @@ exports.SynthesizePolicyToolShape = {
68
73
  * (from `synthesize_policy` under `explain`) plus the recording it was
69
74
  * synthesised from. */
70
75
  const PolicyCheckToolShape = {
71
- predicate: run_1.PredicateNodeSchema,
72
- permitTx: run_1.RecordedTransactionSchema,
76
+ // Supply EITHER `transactionHash` OR both `predicate` and `permitTx`. The tree comes
77
+ // back from `synthesize_policy` only under `explain`, so a caller who did not
78
+ // ask for it has nothing to pass and would skip the check - and this IS the
79
+ // check. `transactionHash` lets the server rebuild both from the transaction.
80
+ predicate: run_1.PredicateNodeSchema.optional(),
81
+ // The canonical encoding `declare_policy` and `synthesize_policy` both
82
+ // return. One opaque string, where the tree is what callers mistype.
83
+ encodedPredicate: zod_1.z.string().optional(),
84
+ permitTx: run_1.RecordedTransactionSchema.optional(),
85
+ transactionHash: zod_1.z.string().optional(),
86
+ network: run_1.NetworkSchema.optional(),
87
+ // Needed with `transactionHash`: the predicate is lowered against the account it will
88
+ // be installed on.
89
+ smartAccount: zod_1.z.string().optional(),
90
+ userResponses: run_1.ComposeUserResponsesSchema.optional(),
73
91
  validUntilLedger: zod_1.z.number().int().positive().optional(),
74
92
  };
75
93
  exports.SimulatePolicyToolShape = { ...PolicyCheckToolShape };
@@ -135,8 +153,46 @@ exports.InstallPolicyToolShape = {
135
153
  * authority scan; omitting them returns `authorityScan: null`, which means
136
154
  * "not checked" rather than "nothing found". */
137
155
  existingRules: zod_1.z.array(zod_1.z.unknown()).optional(),
156
+ // Supply EITHER `rule` (the whole ContextRuleDraft) OR `fromHash`. Same
157
+ // reason as `synthesize_policy`: a draft nests i128 strings, a signer array
158
+ // and a policy array, and an agent retyping it out of its own transcript
159
+ // loses their types. `fromHash` re-derives the draft server-side from the
160
+ // recording, so the install runs against the rule the synthesizer produced.
138
161
  rule: zod_1.z.unknown().optional(),
162
+ // Install a predicate you already hold - the base64 string `declare_policy`
163
+ // returns - naming the keys it governs as plain account addresses. Without
164
+ // this there is no route from `declare_policy` to here.
165
+ fromPredicate: zod_1.z
166
+ .object({
167
+ encodedPredicate: zod_1.z.string(),
168
+ signers: zod_1.z.array(zod_1.z.string()),
169
+ name: zod_1.z.string().optional(),
170
+ validUntilLedger: zod_1.z.number().int().positive().optional(),
171
+ })
172
+ .optional(),
173
+ fromHash: zod_1.z
174
+ .object({
175
+ transactionHash: zod_1.z.string(),
176
+ // The keys the rule governs, as plain Stellar addresses. Synthesis does
177
+ // not choose them, and a rule governing no key is refused on chain.
178
+ signers: zod_1.z.array(zod_1.z.string()).optional(),
179
+ userResponses: run_1.ComposeUserResponsesSchema.optional(),
180
+ })
181
+ .optional(),
139
182
  installNonce: zod_1.z.number().int().positive().optional(),
183
+ // A ROLLING TOTAL beside the per-call predicate, as an OpenZeppelin
184
+ // spending_limit on the same rule. Both must permit. This is the only way to
185
+ // express "N per day": the interpreter is handed one call and keeps no state.
186
+ spendingLimit: zod_1.z
187
+ .object({
188
+ amount: zod_1.z.string(),
189
+ periodLedgers: zod_1.z.number().int().positive(),
190
+ })
191
+ .optional(),
192
+ // Opt-in to installing a rule that bounds no amount when the recording showed
193
+ // a spend. Default-deny: such a rule installs and verifies cleanly and caps
194
+ // nothing.
195
+ allowUnboundedAmount: zod_1.z.boolean().optional(),
140
196
  interpreterAddress: zod_1.z.string().optional(),
141
197
  };
142
198
  /** Flat ZodRawShape for `revoke_policy`. */
@@ -26,18 +26,22 @@ const toCallToolResult = (res) => (0, result_ts_1.mcpResultFromCore)(res);
26
26
  /** Build a fresh, stateless MCP server. The caller owns the returned object
27
27
  * and connects it to a single transport (stdio or Streamable HTTP). */
28
28
  function createMcpServer() {
29
- const server = new mcp_js_1.McpServer({ name: 'policy-builder-mcp', version: '1.0.0' }, { capabilities: { tools: {} } });
29
+ const server = new mcp_js_1.McpServer(
30
+ // Kept in step with package.json by `server-version.test.ts`. It was
31
+ // hardcoded once already and reported 0.0.0 to every client; a literal is
32
+ // fine, a literal nothing checks is not.
33
+ { name: 'policy-builder-mcp', version: '1.1.0' }, { capabilities: { tools: {} } });
30
34
  registerTools(server);
31
35
  return server;
32
36
  }
33
37
  /** Idempotent registration of the T1 tool set on the given server. */
34
38
  function registerTools(server) {
35
39
  server.tool('record_transaction', 'Decode a Soroban transaction (on-chain hash OR base64 envelope XDR) into a RecordedTransaction. Returns a machine-readable ToolError on validation failure.', schemas_ts_1.RecordTransactionToolShape, (args) => (0, run_1.runRecordTransaction)(args).then(toCallToolResult));
36
- server.tool('synthesize_policy', 'Synthesize a ProposedPolicy from a RecordedTransaction (`source: recording`).', schemas_ts_1.SynthesizePolicyToolShape, (args) => (0, run_1.runSynthesizePolicy)(args).then(toCallToolResult));
37
- server.tool('simulate_policy', '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 the `predicate` returned by `synthesize_policy` under `explain`.', schemas_ts_1.SimulatePolicyToolShape, (args) => toCallToolResult((0, run_1.runSimulatePolicy)(args)));
38
- server.tool('verify_policy', '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.', schemas_ts_1.VerifyPolicyToolShape, (args) => toCallToolResult((0, run_1.runVerifyPolicy)(args)));
40
+ server.tool('synthesize_policy', '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.', schemas_ts_1.SynthesizePolicyToolShape, (args) => (0, run_1.runSynthesizePolicy)(args).then(toCallToolResult));
41
+ server.tool('simulate_policy', '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.', schemas_ts_1.SimulatePolicyToolShape, (args) => (0, run_1.runSimulatePolicy)(args).then(toCallToolResult));
42
+ server.tool('verify_policy', '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.', schemas_ts_1.VerifyPolicyToolShape, (args) => (0, run_1.runVerifyPolicy)(args).then(toCallToolResult));
39
43
  server.tool('declare_policy', "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.", schemas_ts_1.DeclarePolicyToolShape, (args) => Promise.resolve((0, run_1.runDeclarePolicy)(args)).then(toCallToolResult));
40
- server.tool('install_policy', 'Build an UNSIGNED Soroban transaction XDR for `account.add_context_rule(...)` that installs a new policy rule on the given smart account. 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). Only CALL 1 is emitted; the interpreter `install` follow-up needs the rule id the account assigns in call 1 and is documented in `followUp` in the response.', schemas_ts_1.InstallPolicyToolShape, (args) => (0, run_1.runInstallPolicy)(args).then(toCallToolResult));
44
+ server.tool('install_policy', '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.', schemas_ts_1.InstallPolicyToolShape, (args) => (0, run_1.runInstallPolicy)(args).then(toCallToolResult));
41
45
  server.tool('revoke_policy', '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.', schemas_ts_1.RevokePolicyToolShape, (args) => (0, run_1.runRevokePolicy)(args).then(toCallToolResult));
42
46
  server.tool('get_interpreter_info', '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.', schemas_ts_1.GetInterpreterInfoToolShape, (args) => (0, run_1.runGetInterpreterInfo)(args).then(toCallToolResult));
43
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-builder-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "description": "MCP server exposing the OZ policy-synth core (record_transaction + synthesize_policy) over stdio and Streamable HTTP transports.",
6
6
  "type": "module",
@@ -63,7 +63,7 @@
63
63
  "prepack": "bun run build"
64
64
  },
65
65
  "dependencies": {
66
- "@crediolabs/policy-synth": "1.0.0",
66
+ "@crediolabs/policy-synth": "1.1.0",
67
67
  "@modelcontextprotocol/sdk": "1.30.0",
68
68
  "@stellar/stellar-sdk": "14.4.0",
69
69
  "zod": "3.25.76"
package/src/schemas.ts CHANGED
@@ -70,7 +70,12 @@ export const RecordTransactionToolShape = {
70
70
  * re-validates against the strict schema. */
71
71
  export const SynthesizePolicyToolShape = {
72
72
  source: z.literal('recording').optional(),
73
+ // Supply EITHER `recordedTx` (the whole recording) OR `transactionHash`. An agent
74
+ // should prefer the hash: it cannot copy a multi-thousand-character recording back out
75
+ // of its own transcript without losing fields, and the server can re-record
76
+ // from the hash deterministically.
73
77
  recordedTx: RecordedTransactionSchema.optional(),
78
+ transactionHash: z.string().optional(),
74
79
  network: NetworkSchema.optional(),
75
80
  userResponses: ComposeUserResponsesSchema.optional(),
76
81
  confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
@@ -89,8 +94,21 @@ export const SynthesizePolicyToolShape = {
89
94
  * (from `synthesize_policy` under `explain`) plus the recording it was
90
95
  * synthesised from. */
91
96
  const PolicyCheckToolShape = {
92
- predicate: PredicateNodeSchema,
93
- permitTx: RecordedTransactionSchema,
97
+ // Supply EITHER `transactionHash` OR both `predicate` and `permitTx`. The tree comes
98
+ // back from `synthesize_policy` only under `explain`, so a caller who did not
99
+ // ask for it has nothing to pass and would skip the check - and this IS the
100
+ // check. `transactionHash` lets the server rebuild both from the transaction.
101
+ predicate: PredicateNodeSchema.optional(),
102
+ // The canonical encoding `declare_policy` and `synthesize_policy` both
103
+ // return. One opaque string, where the tree is what callers mistype.
104
+ encodedPredicate: z.string().optional(),
105
+ permitTx: RecordedTransactionSchema.optional(),
106
+ transactionHash: z.string().optional(),
107
+ network: NetworkSchema.optional(),
108
+ // Needed with `transactionHash`: the predicate is lowered against the account it will
109
+ // be installed on.
110
+ smartAccount: z.string().optional(),
111
+ userResponses: ComposeUserResponsesSchema.optional(),
94
112
  validUntilLedger: z.number().int().positive().optional(),
95
113
  } as const
96
114
 
@@ -168,8 +186,46 @@ export const InstallPolicyToolShape = {
168
186
  * authority scan; omitting them returns `authorityScan: null`, which means
169
187
  * "not checked" rather than "nothing found". */
170
188
  existingRules: z.array(z.unknown()).optional(),
189
+ // Supply EITHER `rule` (the whole ContextRuleDraft) OR `fromHash`. Same
190
+ // reason as `synthesize_policy`: a draft nests i128 strings, a signer array
191
+ // and a policy array, and an agent retyping it out of its own transcript
192
+ // loses their types. `fromHash` re-derives the draft server-side from the
193
+ // recording, so the install runs against the rule the synthesizer produced.
171
194
  rule: z.unknown().optional(),
195
+ // Install a predicate you already hold - the base64 string `declare_policy`
196
+ // returns - naming the keys it governs as plain account addresses. Without
197
+ // this there is no route from `declare_policy` to here.
198
+ fromPredicate: z
199
+ .object({
200
+ encodedPredicate: z.string(),
201
+ signers: z.array(z.string()),
202
+ name: z.string().optional(),
203
+ validUntilLedger: z.number().int().positive().optional(),
204
+ })
205
+ .optional(),
206
+ fromHash: z
207
+ .object({
208
+ transactionHash: z.string(),
209
+ // The keys the rule governs, as plain Stellar addresses. Synthesis does
210
+ // not choose them, and a rule governing no key is refused on chain.
211
+ signers: z.array(z.string()).optional(),
212
+ userResponses: ComposeUserResponsesSchema.optional(),
213
+ })
214
+ .optional(),
172
215
  installNonce: z.number().int().positive().optional(),
216
+ // A ROLLING TOTAL beside the per-call predicate, as an OpenZeppelin
217
+ // spending_limit on the same rule. Both must permit. This is the only way to
218
+ // express "N per day": the interpreter is handed one call and keeps no state.
219
+ spendingLimit: z
220
+ .object({
221
+ amount: z.string(),
222
+ periodLedgers: z.number().int().positive(),
223
+ })
224
+ .optional(),
225
+ // Opt-in to installing a rule that bounds no amount when the recording showed
226
+ // a spend. Default-deny: such a rule installs and verifies cleanly and caps
227
+ // nothing.
228
+ allowUnboundedAmount: z.boolean().optional(),
173
229
  interpreterAddress: z.string().optional(),
174
230
  } as const
175
231
 
package/src/server.ts CHANGED
@@ -48,7 +48,10 @@ const toCallToolResult = <T>(
48
48
  * and connects it to a single transport (stdio or Streamable HTTP). */
49
49
  export function createMcpServer(): McpServer {
50
50
  const server = new McpServer(
51
- { name: 'policy-builder-mcp', version: '1.0.0' },
51
+ // Kept in step with package.json by `server-version.test.ts`. It was
52
+ // hardcoded once already and reported 0.0.0 to every client; a literal is
53
+ // fine, a literal nothing checks is not.
54
+ { name: 'policy-builder-mcp', version: '1.1.0' },
52
55
  { capabilities: { tools: {} } }
53
56
  )
54
57
  registerTools(server)
@@ -66,23 +69,23 @@ export function registerTools(server: McpServer): void {
66
69
 
67
70
  server.tool(
68
71
  'synthesize_policy',
69
- 'Synthesize a ProposedPolicy from a RecordedTransaction (`source: recording`).',
72
+ '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.',
70
73
  SynthesizePolicyToolShape,
71
74
  (args) => runSynthesizePolicy(args).then(toCallToolResult)
72
75
  )
73
76
 
74
77
  server.tool(
75
78
  'simulate_policy',
76
- '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 the `predicate` returned by `synthesize_policy` under `explain`.',
79
+ '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.',
77
80
  SimulatePolicyToolShape,
78
- (args) => toCallToolResult(runSimulatePolicy(args))
81
+ (args) => runSimulatePolicy(args).then(toCallToolResult)
79
82
  )
80
83
 
81
84
  server.tool(
82
85
  'verify_policy',
83
- '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.',
86
+ '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.',
84
87
  VerifyPolicyToolShape,
85
- (args) => toCallToolResult(runVerifyPolicy(args))
88
+ (args) => runVerifyPolicy(args).then(toCallToolResult)
86
89
  )
87
90
 
88
91
  server.tool(
@@ -94,7 +97,7 @@ export function registerTools(server: McpServer): void {
94
97
 
95
98
  server.tool(
96
99
  'install_policy',
97
- 'Build an UNSIGNED Soroban transaction XDR for `account.add_context_rule(...)` that installs a new policy rule on the given smart account. 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). Only CALL 1 is emitted; the interpreter `install` follow-up needs the rule id the account assigns in call 1 and is documented in `followUp` in the response.',
100
+ '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.',
98
101
  InstallPolicyToolShape,
99
102
  (args) => runInstallPolicy(args).then(toCallToolResult)
100
103
  )