@crediolabs/policy-builder-mcp 0.1.17 → 0.1.18

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.
package/README.md CHANGED
@@ -1,39 +1,30 @@
1
1
  # @crediolabs/policy-builder-mcp
2
2
 
3
- MCP server exposing the OpenZeppelin Accounts Policy Builder core over stdio and
4
- Streamable HTTP. It wraps [`@crediolabs/policy-synth`](https://www.npmjs.com/package/@crediolabs/policy-synth)
5
- so an agent can record a Soroban transaction and synthesise the minimal policy
6
- that permits exactly that flow. MIT-licensed.
7
-
8
- ## Install
9
-
10
- ```sh
11
- npm install @crediolabs/policy-builder-mcp
12
- # or run without installing:
13
- npx @crediolabs/policy-builder-mcp
14
- ```
3
+ MCP server exposing the OctoGate policy toolchain to agents. It wraps
4
+ [`@crediolabs/policy-synth`](https://www.npmjs.com/package/@crediolabs/policy-synth):
5
+ an agent records a Soroban transaction, synthesises the minimal policy that
6
+ permits exactly that flow, verifies and simulates it, and receives an
7
+ unsigned install transaction for the user's wallet to sign.
15
8
 
16
9
  ## Run
17
10
 
18
11
  ```sh
19
- policy-builder-mcp # stdio transport (default; Claude Desktop / local agents)
20
- policy-builder-mcp --http # Streamable HTTP transport on default port 3001
21
- policy-builder-mcp --http --http-port N # Streamable HTTP transport on port N
22
- ```
12
+ # stdio (default; Claude Desktop and local agents)
13
+ bunx @crediolabs/policy-builder-mcp
23
14
 
24
- Note: `--http-port` only takes effect when `--http` is also supplied (without
25
- `--http` the server stays on stdio regardless of `--http-port`). The port
26
- override must be an integer in `1..65535`; otherwise the default `3001` is
27
- used. Source of truth: [`bin/server.ts`](./bin/server.ts).
15
+ # Streamable HTTP on localhost:3001
16
+ bunx @crediolabs/policy-builder-mcp --http
17
+ bunx @crediolabs/policy-builder-mcp --http --http-port 8080
18
+ ```
28
19
 
29
- ### Use with an MCP client (e.g. Claude Desktop)
20
+ Claude Desktop / Claude Code configuration:
30
21
 
31
22
  ```json
32
23
  {
33
24
  "mcpServers": {
34
25
  "policy-builder": {
35
- "command": "npx",
36
- "args": ["-y", "@crediolabs/policy-builder-mcp"]
26
+ "command": "bunx",
27
+ "args": ["@crediolabs/policy-builder-mcp"]
37
28
  }
38
29
  }
39
30
  }
@@ -41,35 +32,31 @@ used. Source of truth: [`bin/server.ts`](./bin/server.ts).
41
32
 
42
33
  ## Tools
43
34
 
44
- - **`record_transaction`** decode a Soroban transaction (on-chain `hash` **or**
45
- base64 envelope `xdr`, plus `network`) into a `RecordedTransaction`.
46
- - **`synthesize_policy`** synthesise a proposed policy from either a
47
- deterministic mandate (`source: "mandate"`) or a recording
48
- (`source: "recording"`); the `source` field selects the front-end.
49
-
50
- Each tool returns the core's structured result. On invalid input it returns a
51
- machine-readable `ToolError` (never an uncaught throw), so the agent keeps a
52
- usable, actionable payload.
53
-
54
- ## Programmatic use
55
-
56
- ```ts
57
- import { createMcpServer, startStdioServer, startHttpServer } from '@crediolabs/policy-builder-mcp'
58
-
59
- await startStdioServer() // or: await startHttpServer({ port: 3001 })
60
-
61
- // or wire the server into your own transport:
62
- const server = createMcpServer()
63
- ```
64
-
65
- The server is stateless: a fresh instance is built per transport, and nothing
66
- caches, queues, or holds key material.
67
-
68
- ## Status
69
-
70
- The `record_transaction` and `synthesize_policy` tools are implemented and
71
- test-covered. Install, on-chain verify, and simulate are later phases.
35
+ | Tool | Purpose |
36
+ | --- | --- |
37
+ | `record_transaction` | Decode a transaction (hash or envelope XDR) into a `RecordedTransaction`. |
38
+ | `synthesize_policy` | Synthesise a `ProposedPolicy` from a recording or a `MandateSpec`. |
39
+ | `simulate_policy` | Replay a recording against a proposed predicate; run the deny-case battery. |
40
+ | `verify_policy` | Static minimality check on a proposed predicate. |
41
+ | `install_policy` | Build the unsigned `add_context_rule` transaction XDR. |
42
+ | `revoke_policy` | Build the unsigned `remove_context_rule` transaction XDR. |
43
+ | `get_interpreter_info` | Pinned interpreter address, grammar version and wasm sha256, optionally verified live. |
44
+
45
+ Failures come back as machine-readable tool errors (stable `code`,
46
+ `severity`, `retryable`), never as transport-level throws, so an agent can
47
+ branch on them.
48
+
49
+ ## Security model
50
+
51
+ - **Stateless, no key material.** Install and revoke return *unsigned* XDR;
52
+ the wallet signature is the user-confirmation step.
53
+ - **Loopback only by default.** The HTTP transport refuses to bind a
54
+ non-loopback host; it serves `127.0.0.1`, `::1` or `localhost` unless the
55
+ embedding caller explicitly opts out (`allowExternalHost: true` via the
56
+ programmatic API), because the surface is unauthenticated by design.
57
+ - The on-chain interpreter the policies target is **unaudited**; see the
58
+ [architecture document](https://github.com/untangledfinance/octogate/blob/main/docs/architecture.md).
72
59
 
73
60
  ## License
74
61
 
75
- MIT.
62
+ MIT
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // apps/policy-builder-mcp/bin/server.ts - CLI entry point selecting the transport.
2
+ // packages/policy-builder-mcp/bin/server.ts - CLI entry point selecting the transport.
3
3
  //
4
4
  // Usage:
5
5
  // policy-builder-mcp (stdio - default; Claude Desktop / local agents)
package/dist/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/index.ts - public re-exports for the MCP server package.
1
+ // packages/policy-builder-mcp/src/index.ts - public re-exports for the MCP server package.
2
2
  export { runRecordTransaction, runSynthesizePolicy, } from '@crediolabs/policy-synth/run';
3
3
  export { RecordTransactionToolShape, SynthesizePolicyToolShape, } from "./schemas.js";
4
4
  export { createMcpServer, registerTools } from "./server.js";
@@ -15,7 +15,7 @@ export declare const RecordTransactionToolShape: {
15
15
  * front-end; the body re-validates against the discriminated union. */
16
16
  export declare const SynthesizePolicyToolShape: {
17
17
  readonly source: z.ZodOptional<z.ZodEnum<["mandate", "recording"]>>;
18
- readonly mandate: z.ZodOptional<z.ZodObject<{
18
+ readonly mandate: z.ZodOptional<z.ZodEffects<z.ZodObject<{
19
19
  chain: z.ZodLiteral<"stellar">;
20
20
  contract: z.ZodString;
21
21
  method: z.ZodOptional<z.ZodString>;
@@ -102,6 +102,64 @@ export declare const SynthesizePolicyToolShape: {
102
102
  validUntilLedger?: number | undefined;
103
103
  validUntilUnixSeconds?: number | undefined;
104
104
  }>>;
105
+ }, z.ZodTypeAny, "passthrough">>, z.objectOutputType<{
106
+ chain: z.ZodLiteral<"stellar">;
107
+ contract: z.ZodString;
108
+ method: z.ZodOptional<z.ZodString>;
109
+ spendingLimit: z.ZodOptional<z.ZodObject<{
110
+ token: z.ZodString;
111
+ limit: z.ZodString;
112
+ windowSeconds: z.ZodNumber;
113
+ }, "strip", z.ZodTypeAny, {
114
+ token: string;
115
+ limit: string;
116
+ windowSeconds: number;
117
+ }, {
118
+ token: string;
119
+ limit: string;
120
+ windowSeconds: number;
121
+ }>>;
122
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
123
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
124
+ expiry: z.ZodOptional<z.ZodObject<{
125
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
126
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
127
+ }, "strip", z.ZodTypeAny, {
128
+ validUntilLedger?: number | undefined;
129
+ validUntilUnixSeconds?: number | undefined;
130
+ }, {
131
+ validUntilLedger?: number | undefined;
132
+ validUntilUnixSeconds?: number | undefined;
133
+ }>>;
134
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
135
+ chain: z.ZodLiteral<"stellar">;
136
+ contract: z.ZodString;
137
+ method: z.ZodOptional<z.ZodString>;
138
+ spendingLimit: z.ZodOptional<z.ZodObject<{
139
+ token: z.ZodString;
140
+ limit: z.ZodString;
141
+ windowSeconds: z.ZodNumber;
142
+ }, "strip", z.ZodTypeAny, {
143
+ token: string;
144
+ limit: string;
145
+ windowSeconds: number;
146
+ }, {
147
+ token: string;
148
+ limit: string;
149
+ windowSeconds: number;
150
+ }>>;
151
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
152
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
153
+ expiry: z.ZodOptional<z.ZodObject<{
154
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
155
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
156
+ }, "strip", z.ZodTypeAny, {
157
+ validUntilLedger?: number | undefined;
158
+ validUntilUnixSeconds?: number | undefined;
159
+ }, {
160
+ validUntilLedger?: number | undefined;
161
+ validUntilUnixSeconds?: number | undefined;
162
+ }>>;
105
163
  }, z.ZodTypeAny, "passthrough">>>;
106
164
  readonly recordedTx: z.ZodOptional<z.ZodObject<{
107
165
  network: z.ZodEnum<["mainnet", "testnet"]>;
@@ -1028,20 +1086,20 @@ export type { GetInterpreterInfoInput, InstallPolicyInput, RevokePolicyInput, Si
1028
1086
  * (the policy already carries the encoded predicate); the run-layer
1029
1087
  * extracts them from there. */
1030
1088
  export declare const InstallPolicyToolShape: {
1031
- readonly smartAccount: z.ZodOptional<z.ZodString>;
1032
- readonly sourceAccount: z.ZodOptional<z.ZodString>;
1033
1089
  readonly rule: z.ZodOptional<z.ZodUnknown>;
1034
1090
  readonly installNonce: z.ZodOptional<z.ZodNumber>;
1035
1091
  readonly interpreterAddress: z.ZodOptional<z.ZodString>;
1092
+ readonly smartAccount: z.ZodOptional<z.ZodString>;
1093
+ readonly sourceAccount: z.ZodOptional<z.ZodString>;
1036
1094
  readonly rpcUrl: z.ZodOptional<z.ZodString>;
1037
1095
  readonly baseFee: z.ZodOptional<z.ZodNumber>;
1038
1096
  };
1039
1097
  /** Flat ZodRawShape for `revoke_policy`. */
1040
1098
  export declare const RevokePolicyToolShape: {
1041
- readonly smartAccount: z.ZodOptional<z.ZodString>;
1042
- readonly sourceAccount: z.ZodOptional<z.ZodString>;
1043
1099
  readonly ruleId: z.ZodOptional<z.ZodNumber>;
1044
1100
  readonly interpreterAddress: z.ZodOptional<z.ZodString>;
1101
+ readonly smartAccount: z.ZodOptional<z.ZodString>;
1102
+ readonly sourceAccount: z.ZodOptional<z.ZodString>;
1045
1103
  readonly rpcUrl: z.ZodOptional<z.ZodString>;
1046
1104
  readonly baseFee: z.ZodOptional<z.ZodNumber>;
1047
1105
  };
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/schemas.ts
1
+ // packages/policy-builder-mcp/src/schemas.ts
2
2
  //
3
3
  // MCP-only tool shapes. The CORE input/output schemas and the MCP server
4
4
  // registrations all live on `@crediolabs/policy-synth` (the tool-body glue
@@ -82,6 +82,17 @@ export const VerifyPolicyToolShape = {
82
82
  validUntilLedger: z.number().int().positive().optional(),
83
83
  oraclePricesByAsset: z.record(z.string(), OraclePriceFixtureSchema).optional(),
84
84
  };
85
+ /** Common base for `install_policy` and `revoke_policy`: smartAccount,
86
+ * sourceAccount, optional RPC URL, optional base fee. Both share the
87
+ * same smart-account context, so the SDK-emitted JSON Schema stays
88
+ * identical for those fields. The body re-validates against the strict
89
+ * schemas in `@crediolabs/policy-synth/run`. */
90
+ const SmartAccountToolShape = {
91
+ smartAccount: z.string().min(1).optional(),
92
+ sourceAccount: z.string().min(1).optional(),
93
+ rpcUrl: z.string().url().optional(),
94
+ baseFee: z.number().int().positive().optional(),
95
+ };
85
96
  /** Flat ZodRawShape for `install_policy`. `rule` is typed as `z.unknown()`
86
97
  * at the tool boundary because the rule schema is a discriminated union
87
98
  * the SDK does not accept at registration; the body re-validates
@@ -90,22 +101,16 @@ export const VerifyPolicyToolShape = {
90
101
  * (the policy already carries the encoded predicate); the run-layer
91
102
  * extracts them from there. */
92
103
  export const InstallPolicyToolShape = {
93
- smartAccount: z.string().min(1).optional(),
94
- sourceAccount: z.string().min(1).optional(),
104
+ ...SmartAccountToolShape,
95
105
  rule: z.unknown().optional(),
96
106
  installNonce: z.number().int().positive().optional(),
97
107
  interpreterAddress: z.string().optional(),
98
- rpcUrl: z.string().url().optional(),
99
- baseFee: z.number().int().positive().optional(),
100
108
  };
101
109
  /** Flat ZodRawShape for `revoke_policy`. */
102
110
  export const RevokePolicyToolShape = {
103
- smartAccount: z.string().min(1).optional(),
104
- sourceAccount: z.string().min(1).optional(),
111
+ ...SmartAccountToolShape,
105
112
  ruleId: z.number().int().nonnegative().optional(),
106
113
  interpreterAddress: z.string().optional(),
107
- rpcUrl: z.string().url().optional(),
108
- baseFee: z.number().int().positive().optional(),
109
114
  };
110
115
  /** Flat ZodRawShape for `get_interpreter_info`. `verifyLive` triggers an
111
116
  * optional RPC `grammar_version()` call so the caller can verify the
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/server.ts
1
+ // packages/policy-builder-mcp/src/server.ts
2
2
  //
3
3
  // Registers the T1 tool surface on a fresh McpServer instance. The
4
4
  // registration uses the official MCP SDK's `tool()` API with ZodRawShape
@@ -14,6 +14,11 @@ import { runGetInterpreterInfo, runInstallPolicy, runRecordTransaction, runRevok
14
14
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
15
15
  import { GetInterpreterInfoToolShape, InstallPolicyToolShape, RecordTransactionToolShape, RevokePolicyToolShape, SimulatePolicyToolShape, SynthesizePolicyToolShape, VerifyPolicyToolShape, } from "./schemas.js";
16
16
  import { mcpResultFromCore } from "./tools/result.js";
17
+ /** Our envelope types `structuredContent` precisely (T / ToolError); the SDK's
18
+ * CallToolResult widens it to Record<string, unknown>, so the nominal types
19
+ * do not overlap. The runtime shapes match, so we assert through `unknown`
20
+ * at the transport boundary - in one place, used by every tool. */
21
+ const toCallToolResult = (res) => mcpResultFromCore(res);
17
22
  /** Build a fresh, stateless MCP server. The caller owns the returned object
18
23
  * and connects it to a single transport (stdio or Streamable HTTP). */
19
24
  export function createMcpServer() {
@@ -23,36 +28,11 @@ export function createMcpServer() {
23
28
  }
24
29
  /** Idempotent registration of the T1 tool set on the given server. */
25
30
  export function registerTools(server) {
26
- 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, async (args) => {
27
- const res = await runRecordTransaction(args);
28
- // Our envelope types `structuredContent` precisely (T / ToolError); the
29
- // SDK's CallToolResult widens it to Record<string, unknown>, so the
30
- // nominal types do not overlap. The runtime shapes match, so assert
31
- // through `unknown` at this transport boundary.
32
- return mcpResultFromCore(res);
33
- });
34
- 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.', SynthesizePolicyToolShape, async (args) => {
35
- const res = await runSynthesizePolicy(args);
36
- return mcpResultFromCore(res);
37
- });
38
- 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.', SimulatePolicyToolShape, async (args) => {
39
- const res = await runSimulatePolicy(args);
40
- return mcpResultFromCore(res);
41
- });
42
- 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.', VerifyPolicyToolShape, async (args) => {
43
- const res = await runVerifyPolicy(args);
44
- return mcpResultFromCore(res);
45
- });
46
- 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.', InstallPolicyToolShape, async (args) => {
47
- const res = await runInstallPolicy(args);
48
- return mcpResultFromCore(res);
49
- });
50
- 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, async (args) => {
51
- const res = await runRevokePolicy(args);
52
- return mcpResultFromCore(res);
53
- });
54
- server.tool('get_interpreter_info', 'Read-only fingerprint lookup for the policy interpreter contract: returns the pinned address, grammar version, and wasm sha256 (from DEPLOYMENTS.md + 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, async (args) => {
55
- const res = await runGetInterpreterInfo(args);
56
- return mcpResultFromCore(res);
57
- });
31
+ 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 either a deterministic MandateSpec (`source: mandate`) or a RecordedTransaction (`source: recording`). The discriminated `source` field selects the front-end.', SynthesizePolicyToolShape, (args) => runSynthesizePolicy(args).then(toCallToolResult));
33
+ 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.', SimulatePolicyToolShape, (args) => runSimulatePolicy(args).then(toCallToolResult));
34
+ 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.', VerifyPolicyToolShape, (args) => runVerifyPolicy(args).then(toCallToolResult));
35
+ 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.', InstallPolicyToolShape, (args) => runInstallPolicy(args).then(toCallToolResult));
36
+ 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));
37
+ 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));
58
38
  }
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/tools/result.ts
1
+ // packages/policy-builder-mcp/src/tools/result.ts
2
2
  //
3
3
  // Transport-layer mapping between the core's ToolResponse<T> envelope and the
4
4
  // MCP result envelope. This is the ONLY place the two envelopes meet; no
@@ -15,14 +15,13 @@
15
15
  /** Map a core ToolResponse<T> to the MCP result envelope. The handler in
16
16
  * src/server.ts wraps this in the SDK's `{ content, isError }` shape. */
17
17
  export function mcpResultFromCore(res) {
18
- if (res.ok) {
19
- return {
20
- isError: false,
21
- content: [{ type: 'text', text: JSON.stringify(res.data) }],
22
- structuredContent: res.data,
23
- };
24
- }
25
- return mcpErrorFromCore(res.error);
18
+ if (!res.ok)
19
+ return mcpErrorFromCore(res.error);
20
+ return {
21
+ isError: false,
22
+ content: [{ type: 'text', text: JSON.stringify(res.data) }],
23
+ structuredContent: res.data,
24
+ };
26
25
  }
27
26
  /** Map a core ToolError directly (for inputs that already failed validation
28
27
  * inside the handler). */
@@ -3,6 +3,12 @@ export interface StartHttpServerOptions {
3
3
  host?: string;
4
4
  /** Path the server mounts the MCP endpoint at. Default `/mcp`. */
5
5
  path?: string;
6
+ /** Opt-in to binding a NON-loopback host (e.g. `0.0.0.0` to expose the
7
+ * server on a LAN / public NIC). The MCP surface is unauthenticated, so
8
+ * this is gated behind a flag: the default refuse-then-opt-in shape
9
+ * keeps the security boundary auditable in code review. A caller that
10
+ * sets this is taking responsibility for downstream auth / firewall. */
11
+ allowExternalHost?: boolean;
6
12
  }
7
13
  export interface RunningHttpServer {
8
14
  port: number;
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/transports/http.ts
1
+ // packages/policy-builder-mcp/src/transports/http.ts
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
@@ -7,15 +7,33 @@
7
7
  //
8
8
  // Single endpoint: POST /mcp (the SDK also accepts GET for SSE streaming, but
9
9
  // the T1 surface does not emit server-initiated messages so we omit it).
10
- // Listens on 127.0.0.1 by default; pass `host: '0.0.0.0'` to expose.
10
+ // Listens on 127.0.0.1 by default.
11
+ //
12
+ // SECURITY BOUNDARY: the MCP server has NO auth - any request is processed.
13
+ // Binding to a non-loopback interface (e.g. `0.0.0.0`, `::`, an external
14
+ // NIC) would expose that unauthenticated surface to every reachable host
15
+ // (LAN peers, public cloud metadata, the open internet on a misconfigured
16
+ // VPS). The host boundary is therefore FAIL-CLOSED: only loopback
17
+ // (`127.0.0.1`, `::1`, `localhost`) is accepted by default. A caller that
18
+ // KNOWS they want to expose the server MUST pass `allowExternalHost: true`
19
+ // explicitly - the flag is the auditable intent. Test runners and the
20
+ // in-process CLI client do not need it; they bind 127.0.0.1 already.
11
21
  import { createServer } from 'node:http';
12
22
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
13
23
  import { createMcpServer } from "../server.js";
24
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
14
25
  /** Stateless Streamable HTTP server. Resolves once the server is listening.
15
26
  * The returned handle exposes `close()` for tests + clean shutdown. */
16
27
  export async function startHttpServer(opts) {
17
28
  const host = opts.host ?? '127.0.0.1';
18
29
  const path = opts.path ?? '/mcp';
30
+ // Default-deny: refuse to bind a non-loopback host unless the caller has
31
+ // explicitly opted in. The MCP server has no auth, so the only thing
32
+ // standing between this binary and an open attack surface on `0.0.0.0` is
33
+ // this check; we would rather fail loudly here than silently expose it.
34
+ if (!LOOPBACK_HOSTS.has(host) && opts.allowExternalHost !== true) {
35
+ 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.`);
36
+ }
19
37
  const server = createMcpServer();
20
38
  // One transport per server (the SDK reuses the transport for every request
21
39
  // in stateless mode). We connect it once at startup and reuse it.
@@ -76,7 +94,10 @@ export async function startHttpServer(opts) {
76
94
  catch {
77
95
  // The SDK normally writes structured errors itself; this is a belt +
78
96
  // braces guard so a thrown error does not leave the socket hanging.
79
- if (!res.headersSent) {
97
+ if (res.headersSent) {
98
+ res.end();
99
+ }
100
+ else {
80
101
  const error = {
81
102
  code: 'SYNTHESIS_ERROR',
82
103
  message: 'internal server error',
@@ -85,8 +106,6 @@ export async function startHttpServer(opts) {
85
106
  };
86
107
  sendJson(res, 500, { error });
87
108
  }
88
- else
89
- res.end();
90
109
  }
91
110
  });
92
111
  await new Promise((resolve, reject) => {
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/transports/stdio.ts
1
+ // packages/policy-builder-mcp/src/transports/stdio.ts
2
2
  //
3
3
  // stdio transport (Claude Desktop / local agents). The MCP SDK reads JSON-RPC
4
4
  // from stdin and writes to stdout. Each process serves ONE client and exits
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-mcp/src/index.ts - public re-exports for the MCP server package.
2
+ // packages/policy-builder-mcp/src/index.ts - public re-exports for the MCP server package.
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.startStdioServer = exports.startHttpServer = exports.mcpResultFromCore = exports.mcpErrorFromCore = exports.registerTools = exports.createMcpServer = exports.SynthesizePolicyToolShape = exports.RecordTransactionToolShape = exports.runSynthesizePolicy = exports.runRecordTransaction = void 0;
5
5
  var run_1 = require("@crediolabs/policy-synth/run");
@@ -15,7 +15,7 @@ export declare const RecordTransactionToolShape: {
15
15
  * front-end; the body re-validates against the discriminated union. */
16
16
  export declare const SynthesizePolicyToolShape: {
17
17
  readonly source: z.ZodOptional<z.ZodEnum<["mandate", "recording"]>>;
18
- readonly mandate: z.ZodOptional<z.ZodObject<{
18
+ readonly mandate: z.ZodOptional<z.ZodEffects<z.ZodObject<{
19
19
  chain: z.ZodLiteral<"stellar">;
20
20
  contract: z.ZodString;
21
21
  method: z.ZodOptional<z.ZodString>;
@@ -102,6 +102,64 @@ export declare const SynthesizePolicyToolShape: {
102
102
  validUntilLedger?: number | undefined;
103
103
  validUntilUnixSeconds?: number | undefined;
104
104
  }>>;
105
+ }, z.ZodTypeAny, "passthrough">>, z.objectOutputType<{
106
+ chain: z.ZodLiteral<"stellar">;
107
+ contract: z.ZodString;
108
+ method: z.ZodOptional<z.ZodString>;
109
+ spendingLimit: z.ZodOptional<z.ZodObject<{
110
+ token: z.ZodString;
111
+ limit: z.ZodString;
112
+ windowSeconds: z.ZodNumber;
113
+ }, "strip", z.ZodTypeAny, {
114
+ token: string;
115
+ limit: string;
116
+ windowSeconds: number;
117
+ }, {
118
+ token: string;
119
+ limit: string;
120
+ windowSeconds: number;
121
+ }>>;
122
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
123
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
124
+ expiry: z.ZodOptional<z.ZodObject<{
125
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
126
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
127
+ }, "strip", z.ZodTypeAny, {
128
+ validUntilLedger?: number | undefined;
129
+ validUntilUnixSeconds?: number | undefined;
130
+ }, {
131
+ validUntilLedger?: number | undefined;
132
+ validUntilUnixSeconds?: number | undefined;
133
+ }>>;
134
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
135
+ chain: z.ZodLiteral<"stellar">;
136
+ contract: z.ZodString;
137
+ method: z.ZodOptional<z.ZodString>;
138
+ spendingLimit: z.ZodOptional<z.ZodObject<{
139
+ token: z.ZodString;
140
+ limit: z.ZodString;
141
+ windowSeconds: z.ZodNumber;
142
+ }, "strip", z.ZodTypeAny, {
143
+ token: string;
144
+ limit: string;
145
+ windowSeconds: number;
146
+ }, {
147
+ token: string;
148
+ limit: string;
149
+ windowSeconds: number;
150
+ }>>;
151
+ approvalThreshold: z.ZodOptional<z.ZodNumber>;
152
+ recipients: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
153
+ expiry: z.ZodOptional<z.ZodObject<{
154
+ validUntilLedger: z.ZodOptional<z.ZodNumber>;
155
+ validUntilUnixSeconds: z.ZodOptional<z.ZodNumber>;
156
+ }, "strip", z.ZodTypeAny, {
157
+ validUntilLedger?: number | undefined;
158
+ validUntilUnixSeconds?: number | undefined;
159
+ }, {
160
+ validUntilLedger?: number | undefined;
161
+ validUntilUnixSeconds?: number | undefined;
162
+ }>>;
105
163
  }, z.ZodTypeAny, "passthrough">>>;
106
164
  readonly recordedTx: z.ZodOptional<z.ZodObject<{
107
165
  network: z.ZodEnum<["mainnet", "testnet"]>;
@@ -1028,20 +1086,20 @@ export type { GetInterpreterInfoInput, InstallPolicyInput, RevokePolicyInput, Si
1028
1086
  * (the policy already carries the encoded predicate); the run-layer
1029
1087
  * extracts them from there. */
1030
1088
  export declare const InstallPolicyToolShape: {
1031
- readonly smartAccount: z.ZodOptional<z.ZodString>;
1032
- readonly sourceAccount: z.ZodOptional<z.ZodString>;
1033
1089
  readonly rule: z.ZodOptional<z.ZodUnknown>;
1034
1090
  readonly installNonce: z.ZodOptional<z.ZodNumber>;
1035
1091
  readonly interpreterAddress: z.ZodOptional<z.ZodString>;
1092
+ readonly smartAccount: z.ZodOptional<z.ZodString>;
1093
+ readonly sourceAccount: z.ZodOptional<z.ZodString>;
1036
1094
  readonly rpcUrl: z.ZodOptional<z.ZodString>;
1037
1095
  readonly baseFee: z.ZodOptional<z.ZodNumber>;
1038
1096
  };
1039
1097
  /** Flat ZodRawShape for `revoke_policy`. */
1040
1098
  export declare const RevokePolicyToolShape: {
1041
- readonly smartAccount: z.ZodOptional<z.ZodString>;
1042
- readonly sourceAccount: z.ZodOptional<z.ZodString>;
1043
1099
  readonly ruleId: z.ZodOptional<z.ZodNumber>;
1044
1100
  readonly interpreterAddress: z.ZodOptional<z.ZodString>;
1101
+ readonly smartAccount: z.ZodOptional<z.ZodString>;
1102
+ readonly sourceAccount: z.ZodOptional<z.ZodString>;
1045
1103
  readonly rpcUrl: z.ZodOptional<z.ZodString>;
1046
1104
  readonly baseFee: z.ZodOptional<z.ZodNumber>;
1047
1105
  };
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-mcp/src/schemas.ts
2
+ // packages/policy-builder-mcp/src/schemas.ts
3
3
  //
4
4
  // MCP-only tool shapes. The CORE input/output schemas and the MCP server
5
5
  // registrations all live on `@crediolabs/policy-synth` (the tool-body glue
@@ -95,6 +95,17 @@ exports.VerifyPolicyToolShape = {
95
95
  validUntilLedger: zod_1.z.number().int().positive().optional(),
96
96
  oraclePricesByAsset: zod_1.z.record(zod_1.z.string(), run_1.OraclePriceFixtureSchema).optional(),
97
97
  };
98
+ /** Common base for `install_policy` and `revoke_policy`: smartAccount,
99
+ * sourceAccount, optional RPC URL, optional base fee. Both share the
100
+ * same smart-account context, so the SDK-emitted JSON Schema stays
101
+ * identical for those fields. The body re-validates against the strict
102
+ * schemas in `@crediolabs/policy-synth/run`. */
103
+ const SmartAccountToolShape = {
104
+ smartAccount: zod_1.z.string().min(1).optional(),
105
+ sourceAccount: zod_1.z.string().min(1).optional(),
106
+ rpcUrl: zod_1.z.string().url().optional(),
107
+ baseFee: zod_1.z.number().int().positive().optional(),
108
+ };
98
109
  /** Flat ZodRawShape for `install_policy`. `rule` is typed as `z.unknown()`
99
110
  * at the tool boundary because the rule schema is a discriminated union
100
111
  * the SDK does not accept at registration; the body re-validates
@@ -103,22 +114,16 @@ exports.VerifyPolicyToolShape = {
103
114
  * (the policy already carries the encoded predicate); the run-layer
104
115
  * extracts them from there. */
105
116
  exports.InstallPolicyToolShape = {
106
- smartAccount: zod_1.z.string().min(1).optional(),
107
- sourceAccount: zod_1.z.string().min(1).optional(),
117
+ ...SmartAccountToolShape,
108
118
  rule: zod_1.z.unknown().optional(),
109
119
  installNonce: zod_1.z.number().int().positive().optional(),
110
120
  interpreterAddress: zod_1.z.string().optional(),
111
- rpcUrl: zod_1.z.string().url().optional(),
112
- baseFee: zod_1.z.number().int().positive().optional(),
113
121
  };
114
122
  /** Flat ZodRawShape for `revoke_policy`. */
115
123
  exports.RevokePolicyToolShape = {
116
- smartAccount: zod_1.z.string().min(1).optional(),
117
- sourceAccount: zod_1.z.string().min(1).optional(),
124
+ ...SmartAccountToolShape,
118
125
  ruleId: zod_1.z.number().int().nonnegative().optional(),
119
126
  interpreterAddress: zod_1.z.string().optional(),
120
- rpcUrl: zod_1.z.string().url().optional(),
121
- baseFee: zod_1.z.number().int().positive().optional(),
122
127
  };
123
128
  /** Flat ZodRawShape for `get_interpreter_info`. `verifyLive` triggers an
124
129
  * optional RPC `grammar_version()` call so the caller can verify the
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-mcp/src/server.ts
2
+ // packages/policy-builder-mcp/src/server.ts
3
3
  //
4
4
  // Registers the T1 tool surface on a fresh McpServer instance. The
5
5
  // registration uses the official MCP SDK's `tool()` API with ZodRawShape
@@ -18,6 +18,11 @@ const run_1 = require("@crediolabs/policy-synth/run");
18
18
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
19
19
  const schemas_ts_1 = require("./schemas.js");
20
20
  const result_ts_1 = require("./tools/result.js");
21
+ /** Our envelope types `structuredContent` precisely (T / ToolError); the SDK's
22
+ * CallToolResult widens it to Record<string, unknown>, so the nominal types
23
+ * do not overlap. The runtime shapes match, so we assert through `unknown`
24
+ * at the transport boundary - in one place, used by every tool. */
25
+ const toCallToolResult = (res) => (0, result_ts_1.mcpResultFromCore)(res);
21
26
  /** Build a fresh, stateless MCP server. The caller owns the returned object
22
27
  * and connects it to a single transport (stdio or Streamable HTTP). */
23
28
  function createMcpServer() {
@@ -27,36 +32,11 @@ function createMcpServer() {
27
32
  }
28
33
  /** Idempotent registration of the T1 tool set on the given server. */
29
34
  function registerTools(server) {
30
- 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, async (args) => {
31
- const res = await (0, run_1.runRecordTransaction)(args);
32
- // Our envelope types `structuredContent` precisely (T / ToolError); the
33
- // SDK's CallToolResult widens it to Record<string, unknown>, so the
34
- // nominal types do not overlap. The runtime shapes match, so assert
35
- // through `unknown` at this transport boundary.
36
- return (0, result_ts_1.mcpResultFromCore)(res);
37
- });
38
- 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, async (args) => {
39
- const res = await (0, run_1.runSynthesizePolicy)(args);
40
- return (0, result_ts_1.mcpResultFromCore)(res);
41
- });
42
- 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, async (args) => {
43
- const res = await (0, run_1.runSimulatePolicy)(args);
44
- return (0, result_ts_1.mcpResultFromCore)(res);
45
- });
46
- 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, async (args) => {
47
- const res = await (0, run_1.runVerifyPolicy)(args);
48
- return (0, result_ts_1.mcpResultFromCore)(res);
49
- });
50
- 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, async (args) => {
51
- const res = await (0, run_1.runInstallPolicy)(args);
52
- return (0, result_ts_1.mcpResultFromCore)(res);
53
- });
54
- 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, async (args) => {
55
- const res = await (0, run_1.runRevokePolicy)(args);
56
- return (0, result_ts_1.mcpResultFromCore)(res);
57
- });
58
- server.tool('get_interpreter_info', 'Read-only fingerprint lookup for the policy interpreter contract: returns the pinned address, grammar version, and wasm sha256 (from DEPLOYMENTS.md + 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, async (args) => {
59
- const res = await (0, run_1.runGetInterpreterInfo)(args);
60
- return (0, result_ts_1.mcpResultFromCore)(res);
61
- });
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));
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
+ 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
+ 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));
62
42
  }
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-mcp/src/tools/result.ts
2
+ // packages/policy-builder-mcp/src/tools/result.ts
3
3
  //
4
4
  // Transport-layer mapping between the core's ToolResponse<T> envelope and the
5
5
  // MCP result envelope. This is the ONLY place the two envelopes meet; no
@@ -19,14 +19,13 @@ exports.mcpErrorFromCore = mcpErrorFromCore;
19
19
  /** Map a core ToolResponse<T> to the MCP result envelope. The handler in
20
20
  * src/server.ts wraps this in the SDK's `{ content, isError }` shape. */
21
21
  function mcpResultFromCore(res) {
22
- if (res.ok) {
23
- return {
24
- isError: false,
25
- content: [{ type: 'text', text: JSON.stringify(res.data) }],
26
- structuredContent: res.data,
27
- };
28
- }
29
- return mcpErrorFromCore(res.error);
22
+ if (!res.ok)
23
+ return mcpErrorFromCore(res.error);
24
+ return {
25
+ isError: false,
26
+ content: [{ type: 'text', text: JSON.stringify(res.data) }],
27
+ structuredContent: res.data,
28
+ };
30
29
  }
31
30
  /** Map a core ToolError directly (for inputs that already failed validation
32
31
  * inside the handler). */
@@ -3,6 +3,12 @@ export interface StartHttpServerOptions {
3
3
  host?: string;
4
4
  /** Path the server mounts the MCP endpoint at. Default `/mcp`. */
5
5
  path?: string;
6
+ /** Opt-in to binding a NON-loopback host (e.g. `0.0.0.0` to expose the
7
+ * server on a LAN / public NIC). The MCP surface is unauthenticated, so
8
+ * this is gated behind a flag: the default refuse-then-opt-in shape
9
+ * keeps the security boundary auditable in code review. A caller that
10
+ * sets this is taking responsibility for downstream auth / firewall. */
11
+ allowExternalHost?: boolean;
6
12
  }
7
13
  export interface RunningHttpServer {
8
14
  port: number;
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-mcp/src/transports/http.ts
2
+ // packages/policy-builder-mcp/src/transports/http.ts
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
@@ -8,17 +8,35 @@
8
8
  //
9
9
  // Single endpoint: POST /mcp (the SDK also accepts GET for SSE streaming, but
10
10
  // the T1 surface does not emit server-initiated messages so we omit it).
11
- // Listens on 127.0.0.1 by default; pass `host: '0.0.0.0'` to expose.
11
+ // Listens on 127.0.0.1 by default.
12
+ //
13
+ // SECURITY BOUNDARY: the MCP server has NO auth - any request is processed.
14
+ // Binding to a non-loopback interface (e.g. `0.0.0.0`, `::`, an external
15
+ // NIC) would expose that unauthenticated surface to every reachable host
16
+ // (LAN peers, public cloud metadata, the open internet on a misconfigured
17
+ // VPS). The host boundary is therefore FAIL-CLOSED: only loopback
18
+ // (`127.0.0.1`, `::1`, `localhost`) is accepted by default. A caller that
19
+ // KNOWS they want to expose the server MUST pass `allowExternalHost: true`
20
+ // explicitly - the flag is the auditable intent. Test runners and the
21
+ // in-process CLI client do not need it; they bind 127.0.0.1 already.
12
22
  Object.defineProperty(exports, "__esModule", { value: true });
13
23
  exports.startHttpServer = startHttpServer;
14
24
  const node_http_1 = require("node:http");
15
25
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
16
26
  const server_ts_1 = require("../server.js");
27
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
17
28
  /** Stateless Streamable HTTP server. Resolves once the server is listening.
18
29
  * The returned handle exposes `close()` for tests + clean shutdown. */
19
30
  async function startHttpServer(opts) {
20
31
  const host = opts.host ?? '127.0.0.1';
21
32
  const path = opts.path ?? '/mcp';
33
+ // Default-deny: refuse to bind a non-loopback host unless the caller has
34
+ // explicitly opted in. The MCP server has no auth, so the only thing
35
+ // standing between this binary and an open attack surface on `0.0.0.0` is
36
+ // this check; we would rather fail loudly here than silently expose it.
37
+ if (!LOOPBACK_HOSTS.has(host) && opts.allowExternalHost !== true) {
38
+ 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
+ }
22
40
  const server = (0, server_ts_1.createMcpServer)();
23
41
  // One transport per server (the SDK reuses the transport for every request
24
42
  // in stateless mode). We connect it once at startup and reuse it.
@@ -79,7 +97,10 @@ async function startHttpServer(opts) {
79
97
  catch {
80
98
  // The SDK normally writes structured errors itself; this is a belt +
81
99
  // braces guard so a thrown error does not leave the socket hanging.
82
- if (!res.headersSent) {
100
+ if (res.headersSent) {
101
+ res.end();
102
+ }
103
+ else {
83
104
  const error = {
84
105
  code: 'SYNTHESIS_ERROR',
85
106
  message: 'internal server error',
@@ -88,8 +109,6 @@ async function startHttpServer(opts) {
88
109
  };
89
110
  sendJson(res, 500, { error });
90
111
  }
91
- else
92
- res.end();
93
112
  }
94
113
  });
95
114
  await new Promise((resolve, reject) => {
@@ -1,5 +1,5 @@
1
1
  "use strict";
2
- // apps/policy-builder-mcp/src/transports/stdio.ts
2
+ // packages/policy-builder-mcp/src/transports/stdio.ts
3
3
  //
4
4
  // stdio transport (Claude Desktop / local agents). The MCP SDK reads JSON-RPC
5
5
  // from stdin and writes to stdout. Each process serves ONE client and exits
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crediolabs/policy-builder-mcp",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
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/oz-policy-builder.git",
16
- "directory": "apps/policy-builder-mcp"
15
+ "url": "https://github.com/untangledfinance/octogate.git",
16
+ "directory": "packages/policy-builder-mcp"
17
17
  },
18
- "homepage": "https://github.com/untangledfinance/oz-policy-builder#readme",
18
+ "homepage": "https://github.com/untangledfinance/octogate#readme",
19
19
  "bugs": {
20
- "url": "https://github.com/untangledfinance/oz-policy-builder/issues"
20
+ "url": "https://github.com/untangledfinance/octogate/issues"
21
21
  },
22
22
  "keywords": [
23
23
  "stellar",
@@ -63,7 +63,7 @@
63
63
  "prepack": "bun run build"
64
64
  },
65
65
  "dependencies": {
66
- "@crediolabs/policy-synth": "0.1.16",
66
+ "@crediolabs/policy-synth": "0.1.18",
67
67
  "@modelcontextprotocol/sdk": "1.18.1",
68
68
  "@stellar/stellar-sdk": "14.4.0",
69
69
  "zod": "3.25.76"
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/index.ts - public re-exports for the MCP server package.
1
+ // packages/policy-builder-mcp/src/index.ts - public re-exports for the MCP server package.
2
2
 
3
3
  export {
4
4
  type RunRecordTransactionInput,
package/src/schemas.ts CHANGED
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/schemas.ts
1
+ // packages/policy-builder-mcp/src/schemas.ts
2
2
  //
3
3
  // MCP-only tool shapes. The CORE input/output schemas and the MCP server
4
4
  // registrations all live on `@crediolabs/policy-synth` (the tool-body glue
@@ -134,6 +134,18 @@ export type {
134
134
  VerifyPolicyInput,
135
135
  } from '@crediolabs/policy-synth/run'
136
136
 
137
+ /** Common base for `install_policy` and `revoke_policy`: smartAccount,
138
+ * sourceAccount, optional RPC URL, optional base fee. Both share the
139
+ * same smart-account context, so the SDK-emitted JSON Schema stays
140
+ * identical for those fields. The body re-validates against the strict
141
+ * schemas in `@crediolabs/policy-synth/run`. */
142
+ const SmartAccountToolShape = {
143
+ smartAccount: z.string().min(1).optional(),
144
+ sourceAccount: z.string().min(1).optional(),
145
+ rpcUrl: z.string().url().optional(),
146
+ baseFee: z.number().int().positive().optional(),
147
+ }
148
+
137
149
  /** Flat ZodRawShape for `install_policy`. `rule` is typed as `z.unknown()`
138
150
  * at the tool boundary because the rule schema is a discriminated union
139
151
  * the SDK does not accept at registration; the body re-validates
@@ -142,23 +154,17 @@ export type {
142
154
  * (the policy already carries the encoded predicate); the run-layer
143
155
  * extracts them from there. */
144
156
  export const InstallPolicyToolShape = {
145
- smartAccount: z.string().min(1).optional(),
146
- sourceAccount: z.string().min(1).optional(),
157
+ ...SmartAccountToolShape,
147
158
  rule: z.unknown().optional(),
148
159
  installNonce: z.number().int().positive().optional(),
149
160
  interpreterAddress: z.string().optional(),
150
- rpcUrl: z.string().url().optional(),
151
- baseFee: z.number().int().positive().optional(),
152
161
  } as const
153
162
 
154
163
  /** Flat ZodRawShape for `revoke_policy`. */
155
164
  export const RevokePolicyToolShape = {
156
- smartAccount: z.string().min(1).optional(),
157
- sourceAccount: z.string().min(1).optional(),
165
+ ...SmartAccountToolShape,
158
166
  ruleId: z.number().int().nonnegative().optional(),
159
167
  interpreterAddress: z.string().optional(),
160
- rpcUrl: z.string().url().optional(),
161
- baseFee: z.number().int().positive().optional(),
162
168
  } as const
163
169
 
164
170
  /** Flat ZodRawShape for `get_interpreter_info`. `verifyLive` triggers an
package/src/server.ts CHANGED
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/server.ts
1
+ // packages/policy-builder-mcp/src/server.ts
2
2
  //
3
3
  // Registers the T1 tool surface on a fresh McpServer instance. The
4
4
  // registration uses the official MCP SDK's `tool()` API with ZodRawShape
@@ -11,6 +11,7 @@
11
11
  // shared mutable state across calls; nothing here caches, queues, or holds
12
12
  // key material.
13
13
 
14
+ import type { ToolResponse } from '@crediolabs/policy-synth'
14
15
  import {
15
16
  runGetInterpreterInfo,
16
17
  runInstallPolicy,
@@ -33,6 +34,14 @@ import {
33
34
  } from './schemas.ts'
34
35
  import { mcpResultFromCore } from './tools/result.ts'
35
36
 
37
+ /** Our envelope types `structuredContent` precisely (T / ToolError); the SDK's
38
+ * CallToolResult widens it to Record<string, unknown>, so the nominal types
39
+ * do not overlap. The runtime shapes match, so we assert through `unknown`
40
+ * at the transport boundary - in one place, used by every tool. */
41
+ const toCallToolResult = <T>(
42
+ res: ToolResponse<T> | (ToolResponse<T> & Record<string, unknown>)
43
+ ): CallToolResult => mcpResultFromCore(res as ToolResponse<T>) as unknown as CallToolResult
44
+
36
45
  /** Build a fresh, stateless MCP server. The caller owns the returned object
37
46
  * and connects it to a single transport (stdio or Streamable HTTP). */
38
47
  export function createMcpServer(): McpServer {
@@ -50,73 +59,48 @@ export function registerTools(server: McpServer): void {
50
59
  'record_transaction',
51
60
  'Decode a Soroban transaction (on-chain hash OR base64 envelope XDR) into a RecordedTransaction. Returns a machine-readable ToolError on validation failure.',
52
61
  RecordTransactionToolShape,
53
- async (args) => {
54
- const res = await runRecordTransaction(args)
55
- // Our envelope types `structuredContent` precisely (T / ToolError); the
56
- // SDK's CallToolResult widens it to Record<string, unknown>, so the
57
- // nominal types do not overlap. The runtime shapes match, so assert
58
- // through `unknown` at this transport boundary.
59
- return mcpResultFromCore(res) as unknown as CallToolResult
60
- }
62
+ (args) => runRecordTransaction(args).then(toCallToolResult)
61
63
  )
62
64
 
63
65
  server.tool(
64
66
  'synthesize_policy',
65
67
  'Synthesize a ProposedPolicy from either a deterministic MandateSpec (`source: mandate`) or a RecordedTransaction (`source: recording`). The discriminated `source` field selects the front-end.',
66
68
  SynthesizePolicyToolShape,
67
- async (args) => {
68
- const res = await runSynthesizePolicy(args)
69
- return mcpResultFromCore(res) as unknown as CallToolResult
70
- }
69
+ (args) => runSynthesizePolicy(args).then(toCallToolResult)
71
70
  )
72
71
 
73
72
  server.tool(
74
73
  'simulate_policy',
75
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.',
76
75
  SimulatePolicyToolShape,
77
- async (args) => {
78
- const res = await runSimulatePolicy(args)
79
- return mcpResultFromCore(res) as unknown as CallToolResult
80
- }
76
+ (args) => runSimulatePolicy(args).then(toCallToolResult)
81
77
  )
82
78
 
83
79
  server.tool(
84
80
  'verify_policy',
85
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.',
86
82
  VerifyPolicyToolShape,
87
- async (args) => {
88
- const res = await runVerifyPolicy(args)
89
- return mcpResultFromCore(res) as unknown as CallToolResult
90
- }
83
+ (args) => runVerifyPolicy(args).then(toCallToolResult)
91
84
  )
92
85
 
93
86
  server.tool(
94
87
  'install_policy',
95
88
  '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.',
96
89
  InstallPolicyToolShape,
97
- async (args) => {
98
- const res = await runInstallPolicy(args)
99
- return mcpResultFromCore(res) as unknown as CallToolResult
100
- }
90
+ (args) => runInstallPolicy(args).then(toCallToolResult)
101
91
  )
102
92
 
103
93
  server.tool(
104
94
  'revoke_policy',
105
95
  '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.',
106
96
  RevokePolicyToolShape,
107
- async (args) => {
108
- const res = await runRevokePolicy(args)
109
- return mcpResultFromCore(res) as unknown as CallToolResult
110
- }
97
+ (args) => runRevokePolicy(args).then(toCallToolResult)
111
98
  )
112
99
 
113
100
  server.tool(
114
101
  'get_interpreter_info',
115
- 'Read-only fingerprint lookup for the policy interpreter contract: returns the pinned address, grammar version, and wasm sha256 (from DEPLOYMENTS.md + 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.',
102
+ '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.',
116
103
  GetInterpreterInfoToolShape,
117
- async (args) => {
118
- const res = await runGetInterpreterInfo(args)
119
- return mcpResultFromCore(res) as unknown as CallToolResult
120
- }
104
+ (args) => runGetInterpreterInfo(args).then(toCallToolResult)
121
105
  )
122
106
  }
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/tools/result.ts
1
+ // packages/policy-builder-mcp/src/tools/result.ts
2
2
  //
3
3
  // Transport-layer mapping between the core's ToolResponse<T> envelope and the
4
4
  // MCP result envelope. This is the ONLY place the two envelopes meet; no
@@ -32,14 +32,12 @@ export type McpToolResult<T> = McpToolSuccess<T> | McpToolError
32
32
  /** Map a core ToolResponse<T> to the MCP result envelope. The handler in
33
33
  * src/server.ts wraps this in the SDK's `{ content, isError }` shape. */
34
34
  export function mcpResultFromCore<T>(res: ToolResponse<T>): McpToolResult<T> {
35
- if (res.ok) {
36
- return {
37
- isError: false,
38
- content: [{ type: 'text', text: JSON.stringify(res.data) }],
39
- structuredContent: res.data,
40
- }
35
+ if (!res.ok) return mcpErrorFromCore(res.error)
36
+ return {
37
+ isError: false,
38
+ content: [{ type: 'text', text: JSON.stringify(res.data) }],
39
+ structuredContent: res.data,
41
40
  }
42
- return mcpErrorFromCore(res.error)
43
41
  }
44
42
 
45
43
  /** Map a core ToolError directly (for inputs that already failed validation
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/transports/http.ts
1
+ // packages/policy-builder-mcp/src/transports/http.ts
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
@@ -7,18 +7,36 @@
7
7
  //
8
8
  // Single endpoint: POST /mcp (the SDK also accepts GET for SSE streaming, but
9
9
  // the T1 surface does not emit server-initiated messages so we omit it).
10
- // Listens on 127.0.0.1 by default; pass `host: '0.0.0.0'` to expose.
10
+ // Listens on 127.0.0.1 by default.
11
+ //
12
+ // SECURITY BOUNDARY: the MCP server has NO auth - any request is processed.
13
+ // Binding to a non-loopback interface (e.g. `0.0.0.0`, `::`, an external
14
+ // NIC) would expose that unauthenticated surface to every reachable host
15
+ // (LAN peers, public cloud metadata, the open internet on a misconfigured
16
+ // VPS). The host boundary is therefore FAIL-CLOSED: only loopback
17
+ // (`127.0.0.1`, `::1`, `localhost`) is accepted by default. A caller that
18
+ // KNOWS they want to expose the server MUST pass `allowExternalHost: true`
19
+ // explicitly - the flag is the auditable intent. Test runners and the
20
+ // in-process CLI client do not need it; they bind 127.0.0.1 already.
11
21
 
12
22
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
13
23
  import type { ToolError } from '@crediolabs/policy-synth'
14
24
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
15
25
  import { createMcpServer } from '../server.ts'
16
26
 
27
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
28
+
17
29
  export interface StartHttpServerOptions {
18
30
  port: number
19
31
  host?: string
20
32
  /** Path the server mounts the MCP endpoint at. Default `/mcp`. */
21
33
  path?: string
34
+ /** Opt-in to binding a NON-loopback host (e.g. `0.0.0.0` to expose the
35
+ * server on a LAN / public NIC). The MCP surface is unauthenticated, so
36
+ * this is gated behind a flag: the default refuse-then-opt-in shape
37
+ * keeps the security boundary auditable in code review. A caller that
38
+ * sets this is taking responsibility for downstream auth / firewall. */
39
+ allowExternalHost?: boolean
22
40
  }
23
41
 
24
42
  export interface RunningHttpServer {
@@ -33,6 +51,15 @@ export interface RunningHttpServer {
33
51
  export async function startHttpServer(opts: StartHttpServerOptions): Promise<RunningHttpServer> {
34
52
  const host = opts.host ?? '127.0.0.1'
35
53
  const path = opts.path ?? '/mcp'
54
+ // Default-deny: refuse to bind a non-loopback host unless the caller has
55
+ // explicitly opted in. The MCP server has no auth, so the only thing
56
+ // standing between this binary and an open attack surface on `0.0.0.0` is
57
+ // this check; we would rather fail loudly here than silently expose it.
58
+ if (!LOOPBACK_HOSTS.has(host) && opts.allowExternalHost !== true) {
59
+ throw new Error(
60
+ `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
+ )
62
+ }
36
63
  const server = createMcpServer()
37
64
  // One transport per server (the SDK reuses the transport for every request
38
65
  // in stateless mode). We connect it once at startup and reuse it.
@@ -94,7 +121,9 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
94
121
  } catch {
95
122
  // The SDK normally writes structured errors itself; this is a belt +
96
123
  // braces guard so a thrown error does not leave the socket hanging.
97
- if (!res.headersSent) {
124
+ if (res.headersSent) {
125
+ res.end()
126
+ } else {
98
127
  const error: ToolError = {
99
128
  code: 'SYNTHESIS_ERROR',
100
129
  message: 'internal server error',
@@ -102,7 +131,7 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
102
131
  retryable: false,
103
132
  }
104
133
  sendJson(res, 500, { error })
105
- } else res.end()
134
+ }
106
135
  }
107
136
  })
108
137
 
@@ -1,4 +1,4 @@
1
- // apps/policy-builder-mcp/src/transports/stdio.ts
1
+ // packages/policy-builder-mcp/src/transports/stdio.ts
2
2
  //
3
3
  // stdio transport (Claude Desktop / local agents). The MCP SDK reads JSON-RPC
4
4
  // from stdin and writes to stdout. Each process serves ONE client and exits