@crediolabs/policy-builder-mcp 0.1.18 → 0.3.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.
@@ -33,9 +33,9 @@ function createMcpServer() {
33
33
  /** Idempotent registration of the T1 tool set on the given server. */
34
34
  function registerTools(server) {
35
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.', schemas_ts_1.RecordTransactionToolShape, (args) => (0, run_1.runRecordTransaction)(args).then(toCallToolResult));
36
- server.tool('synthesize_policy', 'Synthesize a ProposedPolicy from either a deterministic MandateSpec (`source: mandate`) or a RecordedTransaction (`source: recording`). The discriminated `source` field selects the front-end.', schemas_ts_1.SynthesizePolicyToolShape, (args) => (0, run_1.runSynthesizePolicy)(args).then(toCallToolResult));
37
- server.tool('simulate_policy', 'Replay a RecordedTransaction against a proposed PredicateNode (or null for an OZ-only policy) and emit the SimulationResult envelope (permit verdict + deny-case battery). Returns a SIMULATION_ERROR ToolError on runtime evaluation failure.', schemas_ts_1.SimulatePolicyToolShape, (args) => (0, run_1.runSimulatePolicy)(args).then(toCallToolResult));
38
- server.tool('verify_policy', 'Run the static minimality check on a proposed PredicateNode against a RecordedTransaction (no conjunct is load-bearing-free). Returns VERIFICATION_FAILED with the dropped-constraint fingerprints when the predicate is over-broad.', schemas_ts_1.VerifyPolicyToolShape, (args) => (0, run_1.runVerifyPolicy)(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)));
39
39
  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 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));
40
40
  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));
41
41
  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));
@@ -3,8 +3,10 @@
3
3
  //
4
4
  // Streamable HTTP transport (hosted). Uses the Node http module directly so
5
5
  // the package stays thin - no express / hono dep. We run in STATELESS mode
6
- // (sessionIdGenerator: undefined) so each POST /mcp is its own transaction:
7
- // this matches the brief's "stateless across calls" invariant.
6
+ // (no sessionIdGenerator: the SDK disables session management when it is not
7
+ // provided) so each POST /mcp is its own transaction: this matches the
8
+ // brief's "stateless across calls" invariant. Stateless means a fresh server
9
+ // and transport per request - see the handler for why the SDK requires it.
8
10
  //
9
11
  // Single endpoint: POST /mcp (the SDK also accepts GET for SSE streaming, but
10
12
  // the T1 surface does not emit server-initiated messages so we omit it).
@@ -37,13 +39,6 @@ async function startHttpServer(opts) {
37
39
  if (!LOOPBACK_HOSTS.has(host) && opts.allowExternalHost !== true) {
38
40
  throw new Error(`startHttpServer: refusing to bind host ${host}: the MCP surface is unauthenticated, so only loopback (127.0.0.1, ::1, localhost) is permitted by default. Pass \`allowExternalHost: true\` to opt in to a non-loopback bind.`);
39
41
  }
40
- const server = (0, server_ts_1.createMcpServer)();
41
- // One transport per server (the SDK reuses the transport for every request
42
- // in stateless mode). We connect it once at startup and reuse it.
43
- const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
44
- sessionIdGenerator: undefined,
45
- });
46
- await server.connect(transport);
47
42
  const httpServer = (0, node_http_1.createServer)(async (req, res) => {
48
43
  if (!req.url) {
49
44
  sendJson(res, 400, { error: 'missing url' });
@@ -89,7 +84,33 @@ async function startHttpServer(opts) {
89
84
  });
90
85
  return;
91
86
  }
87
+ // A stateless transport handles exactly ONE request: the SDK refuses to
88
+ // reuse one ("Stateless transport cannot be reused across requests"),
89
+ // because a shared instance would let concurrent clients collide on
90
+ // JSON-RPC message ids. So the server and its transport are built here,
91
+ // per request, and torn down when the response closes.
92
+ // No `sessionIdGenerator`: the SDK disables session management when the
93
+ // option is absent, which is the stateless mode this surface wants.
94
+ // Passing an explicit `undefined` means the same thing to the SDK but is
95
+ // not assignable under `exactOptionalPropertyTypes`, so omission is both
96
+ // the type-correct and the documented spelling.
97
+ const server = (0, server_ts_1.createMcpServer)();
98
+ const transport = new streamableHttp_js_1.StreamableHTTPServerTransport();
99
+ // Registered before dispatch, not after: once the response has closed the
100
+ // event is gone, so a listener attached afterwards would never fire and
101
+ // the pair would leak.
102
+ res.on('close', () => {
103
+ void transport.close().catch(() => { });
104
+ void server.close().catch(() => { });
105
+ });
92
106
  try {
107
+ // The SDK declares `Transport.onclose` as an optional `() => void`, but
108
+ // exposes it on this class as an accessor pair typed `(() => void) |
109
+ // undefined`. Those are not assignable under `exactOptionalPropertyTypes`.
110
+ // The widening is upstream and structural only - the runtime object does
111
+ // satisfy `Transport` - so the assertion is narrowed to this one call
112
+ // rather than relaxing the compiler flag for the whole package.
113
+ await server.connect(transport);
93
114
  // `handleRequest` writes the response and returns once the message has
94
115
  // been dispatched. No shared state across calls in stateless mode.
95
116
  await transport.handleRequest(req, res, body);
@@ -122,10 +143,11 @@ async function startHttpServer(opts) {
122
143
  port: opts.port,
123
144
  host,
124
145
  path,
146
+ // Nothing outlives a request, so closing the listener is the whole
147
+ // shutdown: each request's server and transport are already torn down by
148
+ // the `close` handler on its own response.
125
149
  close: async () => {
126
150
  await new Promise((resolve) => httpServer.close(() => resolve()));
127
- await transport.close().catch(() => { });
128
- await server.close().catch(() => { });
129
151
  },
130
152
  };
131
153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-builder-mcp",
3
- "version": "0.1.18",
3
+ "version": "0.3.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",
@@ -12,12 +12,12 @@
12
12
  },
13
13
  "repository": {
14
14
  "type": "git",
15
- "url": "https://github.com/untangledfinance/octogate.git",
15
+ "url": "https://github.com/untangledfinance/oz-policy-builder.git",
16
16
  "directory": "packages/policy-builder-mcp"
17
17
  },
18
- "homepage": "https://github.com/untangledfinance/octogate#readme",
18
+ "homepage": "https://github.com/untangledfinance/oz-policy-builder#readme",
19
19
  "bugs": {
20
- "url": "https://github.com/untangledfinance/octogate/issues"
20
+ "url": "https://github.com/untangledfinance/oz-policy-builder/issues"
21
21
  },
22
22
  "keywords": [
23
23
  "stellar",
@@ -63,8 +63,8 @@
63
63
  "prepack": "bun run build"
64
64
  },
65
65
  "dependencies": {
66
- "@crediolabs/policy-synth": "0.1.18",
67
- "@modelcontextprotocol/sdk": "1.18.1",
66
+ "@crediolabs/policy-synth": "0.3.0",
67
+ "@modelcontextprotocol/sdk": "1.30.0",
68
68
  "@stellar/stellar-sdk": "14.4.0",
69
69
  "zod": "3.25.76"
70
70
  },
package/src/schemas.ts CHANGED
@@ -26,18 +26,13 @@ import {
26
26
  GetInterpreterInfoInputSchema,
27
27
  InstallPolicyInputSchema,
28
28
  InterpreterOptionsSchema,
29
- MandateSpecSchema,
30
29
  NetworkSchema,
31
- OraclePriceFixtureSchema,
32
- OzAdapterConfigSchema,
33
30
  PredicateNodeSchema,
34
31
  RecordedTransactionSchema,
35
32
  RecordTransactionInputSchema,
36
33
  RevokePolicyInputSchema,
37
- SimulatePolicyInputSchema,
38
34
  SynthesizePolicyInputSchema,
39
35
  ToolErrorSchema,
40
- VerifyPolicyInputSchema,
41
36
  } from '@crediolabs/policy-synth/run'
42
37
  import { z } from 'zod'
43
38
 
@@ -51,18 +46,13 @@ export {
51
46
  GetInterpreterInfoInputSchema,
52
47
  InstallPolicyInputSchema,
53
48
  InterpreterOptionsSchema,
54
- MandateSpecSchema,
55
49
  NetworkSchema,
56
- OraclePriceFixtureSchema,
57
- OzAdapterConfigSchema,
58
50
  PredicateNodeSchema,
59
51
  RecordedTransactionSchema,
60
52
  RecordTransactionInputSchema,
61
53
  RevokePolicyInputSchema,
62
- SimulatePolicyInputSchema,
63
54
  SynthesizePolicyInputSchema,
64
55
  ToolErrorSchema,
65
- VerifyPolicyInputSchema,
66
56
  }
67
57
 
68
58
  /** Flat ZodRawShape used for the MCP SDK tool registration. The body
@@ -76,17 +66,15 @@ export const RecordTransactionToolShape = {
76
66
  } as const
77
67
 
78
68
  /** Flat ZodRawShape used for MCP tool registration. Every field is optional
79
- * so the JSON-Schema the SDK exposes to clients does not forbid either
80
- * front-end; the body re-validates against the discriminated union. */
69
+ * so the JSON-Schema the SDK exposes to clients stays permissive; the body
70
+ * re-validates against the strict schema. */
81
71
  export const SynthesizePolicyToolShape = {
82
- source: z.enum(['mandate', 'recording']).optional(),
83
- mandate: MandateSpecSchema.optional(),
72
+ source: z.literal('recording').optional(),
84
73
  recordedTx: RecordedTransactionSchema.optional(),
85
74
  network: NetworkSchema.optional(),
86
75
  userResponses: ComposeUserResponsesSchema.optional(),
87
76
  confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
88
77
  interpreter: InterpreterOptionsSchema.optional(),
89
- ozConfig: OzAdapterConfigSchema.optional(),
90
78
  // Without this the tool chain has no join. A ProposedPolicy carries
91
79
  // `policyDocuments[].encodedPredicate` (canonical ScVal bytes), while
92
80
  // `simulate_policy` and `verify_policy` both want the PredicateNode TREE,
@@ -97,35 +85,18 @@ export const SynthesizePolicyToolShape = {
97
85
  explain: z.boolean().optional(),
98
86
  } as const
99
87
 
100
- /** Flat ZodRawShape for `simulate_policy`. The `predicate` is typed as
101
- * `z.unknown()` at the tool boundary because the recursive
102
- * `PredicateNodeSchema` is a `z.lazy()` union (the SDK does not accept
103
- * unions at the tool-registration boundary); the body re-validates
104
- * against `SimulatePolicyInputSchema`, which fails closed on a
105
- * malformed predicate. `predicate` is nullable here (vs required for
106
- * verify_policy) so the SDK-emitted JSON Schema mirrors the engine's
107
- * "OZ-only / no interpreter predicate" contract. */
108
- export const SimulatePolicyToolShape = {
109
- predicate: z.unknown().nullable().optional(),
88
+ /** `simulate_policy` and `verify_policy` share their input: the predicate tree
89
+ * (from `synthesize_policy` under `explain`) plus the recording it was
90
+ * synthesised from. */
91
+ const PolicyCheckToolShape = {
92
+ predicate: PredicateNodeSchema,
110
93
  permitTx: RecordedTransactionSchema,
111
94
  validUntilLedger: z.number().int().positive().optional(),
112
- oraclePricesByAsset: z.record(z.string(), OraclePriceFixtureSchema).optional(),
113
95
  } as const
114
96
 
115
- /** Flat ZodRawShape for `verify_policy`. Same `z.unknown()` boundary
116
- * trick for `predicate` as `SimulatePolicyToolShape`; `predicate` is
117
- * required at the strict-schema level (`VerifyPolicyInputSchema`) so
118
- * the body fails closed on a missing predicate. */
119
- export const VerifyPolicyToolShape = {
120
- predicate: z.unknown(),
121
- permitTx: RecordedTransactionSchema,
122
- validUntilLedger: z.number().int().positive().optional(),
123
- oraclePricesByAsset: z.record(z.string(), OraclePriceFixtureSchema).optional(),
124
- } as const
97
+ export const SimulatePolicyToolShape = { ...PolicyCheckToolShape } as const
98
+ export const VerifyPolicyToolShape = { ...PolicyCheckToolShape } as const
125
99
 
126
- // Re-export the strict input schemas so MCP consumers (and downstream
127
- // tests) can import the canonical wire shapes from the same module
128
- // that owns the tool-shape glue.
129
100
  export type {
130
101
  GetInterpreterInfoInput,
131
102
  InstallPolicyInput,
package/src/server.ts CHANGED
@@ -64,23 +64,23 @@ export function registerTools(server: McpServer): void {
64
64
 
65
65
  server.tool(
66
66
  'synthesize_policy',
67
- 'Synthesize a ProposedPolicy from either a deterministic MandateSpec (`source: mandate`) or a RecordedTransaction (`source: recording`). The discriminated `source` field selects the front-end.',
67
+ 'Synthesize a ProposedPolicy from a RecordedTransaction (`source: recording`).',
68
68
  SynthesizePolicyToolShape,
69
69
  (args) => runSynthesizePolicy(args).then(toCallToolResult)
70
70
  )
71
71
 
72
72
  server.tool(
73
73
  'simulate_policy',
74
- 'Replay a RecordedTransaction against a proposed PredicateNode (or null for an OZ-only policy) and emit the SimulationResult envelope (permit verdict + deny-case battery). Returns a SIMULATION_ERROR ToolError on runtime evaluation failure.',
74
+ '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`.',
75
75
  SimulatePolicyToolShape,
76
- (args) => runSimulatePolicy(args).then(toCallToolResult)
76
+ (args) => toCallToolResult(runSimulatePolicy(args))
77
77
  )
78
78
 
79
79
  server.tool(
80
80
  'verify_policy',
81
- 'Run the static minimality check on a proposed PredicateNode against a RecordedTransaction (no conjunct is load-bearing-free). Returns VERIFICATION_FAILED with the dropped-constraint fingerprints when the predicate is over-broad.',
81
+ '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.',
82
82
  VerifyPolicyToolShape,
83
- (args) => runVerifyPolicy(args).then(toCallToolResult)
83
+ (args) => toCallToolResult(runVerifyPolicy(args))
84
84
  )
85
85
 
86
86
  server.tool(
@@ -2,8 +2,10 @@
2
2
  //
3
3
  // Streamable HTTP transport (hosted). Uses the Node http module directly so
4
4
  // the package stays thin - no express / hono dep. We run in STATELESS mode
5
- // (sessionIdGenerator: undefined) so each POST /mcp is its own transaction:
6
- // this matches the brief's "stateless across calls" invariant.
5
+ // (no sessionIdGenerator: the SDK disables session management when it is not
6
+ // provided) so each POST /mcp is its own transaction: this matches the
7
+ // brief's "stateless across calls" invariant. Stateless means a fresh server
8
+ // and transport per request - see the handler for why the SDK requires it.
7
9
  //
8
10
  // Single endpoint: POST /mcp (the SDK also accepts GET for SSE streaming, but
9
11
  // the T1 surface does not emit server-initiated messages so we omit it).
@@ -22,6 +24,7 @@
22
24
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
23
25
  import type { ToolError } from '@crediolabs/policy-synth'
24
26
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
27
+ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
25
28
  import { createMcpServer } from '../server.ts'
26
29
 
27
30
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
@@ -60,14 +63,6 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
60
63
  `startHttpServer: refusing to bind host ${host}: the MCP surface is unauthenticated, so only loopback (127.0.0.1, ::1, localhost) is permitted by default. Pass \`allowExternalHost: true\` to opt in to a non-loopback bind.`
61
64
  )
62
65
  }
63
- const server = createMcpServer()
64
- // One transport per server (the SDK reuses the transport for every request
65
- // in stateless mode). We connect it once at startup and reuse it.
66
- const transport = new StreamableHTTPServerTransport({
67
- sessionIdGenerator: undefined,
68
- })
69
- await server.connect(transport)
70
-
71
66
  const httpServer: Server = createServer(async (req, res) => {
72
67
  if (!req.url) {
73
68
  sendJson(res, 400, { error: 'missing url' })
@@ -114,7 +109,33 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
114
109
  return
115
110
  }
116
111
 
112
+ // A stateless transport handles exactly ONE request: the SDK refuses to
113
+ // reuse one ("Stateless transport cannot be reused across requests"),
114
+ // because a shared instance would let concurrent clients collide on
115
+ // JSON-RPC message ids. So the server and its transport are built here,
116
+ // per request, and torn down when the response closes.
117
+ // No `sessionIdGenerator`: the SDK disables session management when the
118
+ // option is absent, which is the stateless mode this surface wants.
119
+ // Passing an explicit `undefined` means the same thing to the SDK but is
120
+ // not assignable under `exactOptionalPropertyTypes`, so omission is both
121
+ // the type-correct and the documented spelling.
122
+ const server = createMcpServer()
123
+ const transport = new StreamableHTTPServerTransport()
124
+ // Registered before dispatch, not after: once the response has closed the
125
+ // event is gone, so a listener attached afterwards would never fire and
126
+ // the pair would leak.
127
+ res.on('close', () => {
128
+ void transport.close().catch(() => {})
129
+ void server.close().catch(() => {})
130
+ })
117
131
  try {
132
+ // The SDK declares `Transport.onclose` as an optional `() => void`, but
133
+ // exposes it on this class as an accessor pair typed `(() => void) |
134
+ // undefined`. Those are not assignable under `exactOptionalPropertyTypes`.
135
+ // The widening is upstream and structural only - the runtime object does
136
+ // satisfy `Transport` - so the assertion is narrowed to this one call
137
+ // rather than relaxing the compiler flag for the whole package.
138
+ await server.connect(transport as unknown as Transport)
118
139
  // `handleRequest` writes the response and returns once the message has
119
140
  // been dispatched. No shared state across calls in stateless mode.
120
141
  await transport.handleRequest(req as IncomingMessage & { auth?: never }, res, body)
@@ -147,10 +168,11 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
147
168
  port: opts.port,
148
169
  host,
149
170
  path,
171
+ // Nothing outlives a request, so closing the listener is the whole
172
+ // shutdown: each request's server and transport are already torn down by
173
+ // the `close` handler on its own response.
150
174
  close: async () => {
151
175
  await new Promise<void>((resolve) => httpServer.close(() => resolve()))
152
- await transport.close().catch(() => {})
153
- await server.close().catch(() => {})
154
176
  },
155
177
  }
156
178
  }