@crediolabs/policy-builder-mcp 0.2.0 → 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.
- package/README.md +3 -3
- package/dist/src/schemas.d.ts +43 -346
- package/dist/src/schemas.js +12 -36
- package/dist/src/server.js +5 -6
- package/dist/src/transports/http.js +32 -34
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/src/index.d.ts +7 -0
- package/dist-cjs/src/index.js +20 -0
- package/dist-cjs/src/schemas.d.ts +869 -0
- package/dist-cjs/src/schemas.js +114 -0
- package/dist-cjs/src/server.d.ts +6 -0
- package/dist-cjs/src/server.js +42 -0
- package/dist-cjs/src/tools/result.d.ts +24 -0
- package/dist-cjs/src/tools/result.js +38 -0
- package/dist-cjs/src/transports/http.d.ts +21 -0
- package/dist-cjs/src/transports/http.js +181 -0
- package/dist-cjs/src/transports/stdio.d.ts +1 -0
- package/dist-cjs/src/transports/stdio.js +18 -0
- package/package.json +11 -7
- package/src/schemas.ts +10 -48
- package/src/server.ts +5 -14
- package/src/transports/http.ts +32 -35
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// packages/policy-builder-mcp/src/schemas.ts
|
|
3
|
+
//
|
|
4
|
+
// MCP-only tool shapes. The CORE input/output schemas and the MCP server
|
|
5
|
+
// registrations all live on `@crediolabs/policy-synth` (the tool-body glue
|
|
6
|
+
// sits at `@crediolabs/policy-synth/run`; the underlying Zod input schemas
|
|
7
|
+
// live at `@crediolabs/policy-synth/run`). What stays here is just the flat
|
|
8
|
+
// `ZodRawShape` needed by `@modelcontextprotocol/sdk`'s `tool()` registration
|
|
9
|
+
// API, which does not accept the strict discriminated union `synthesize_policy`
|
|
10
|
+
// needs.
|
|
11
|
+
//
|
|
12
|
+
// The body of every tool call re-validates against the strict schemas via
|
|
13
|
+
// `runRecordTransaction` / `runSynthesizePolicy`, so wire inputs still
|
|
14
|
+
// fail closed. The mutual-exclusion rules (e.g. exactly-one-of hash/xdr)
|
|
15
|
+
// live on the strict schemas, NOT on the tool shape; the SDK does not
|
|
16
|
+
// invoke `.refine()` at registration time, so any refined rule must be
|
|
17
|
+
// re-checked in the body.
|
|
18
|
+
//
|
|
19
|
+
// The tool-shape fields below are hand-rolled simple types rather than
|
|
20
|
+
// references into the refined strict schemas (`.refine()` returns
|
|
21
|
+
// `ZodEffects`, which has no `.shape`). They MUST stay in lockstep with
|
|
22
|
+
// the strict schemas - a drift in field type or optionality breaks the
|
|
23
|
+
// SDK's emitted JSON Schema.
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.GetInterpreterInfoToolShape = exports.RevokePolicyToolShape = exports.InstallPolicyToolShape = exports.VerifyPolicyToolShape = exports.SimulatePolicyToolShape = exports.SynthesizePolicyToolShape = exports.RecordTransactionToolShape = exports.ToolErrorSchema = exports.SynthesizePolicyInputSchema = exports.RevokePolicyInputSchema = exports.RecordTransactionInputSchema = exports.RecordedTransactionSchema = exports.PredicateNodeSchema = exports.NetworkSchema = exports.InterpreterOptionsSchema = exports.InstallPolicyInputSchema = exports.GetInterpreterInfoInputSchema = exports.ComposeUserResponsesSchema = void 0;
|
|
26
|
+
const run_1 = require("@crediolabs/policy-synth/run");
|
|
27
|
+
Object.defineProperty(exports, "ComposeUserResponsesSchema", { enumerable: true, get: function () { return run_1.ComposeUserResponsesSchema; } });
|
|
28
|
+
Object.defineProperty(exports, "GetInterpreterInfoInputSchema", { enumerable: true, get: function () { return run_1.GetInterpreterInfoInputSchema; } });
|
|
29
|
+
Object.defineProperty(exports, "InstallPolicyInputSchema", { enumerable: true, get: function () { return run_1.InstallPolicyInputSchema; } });
|
|
30
|
+
Object.defineProperty(exports, "InterpreterOptionsSchema", { enumerable: true, get: function () { return run_1.InterpreterOptionsSchema; } });
|
|
31
|
+
Object.defineProperty(exports, "NetworkSchema", { enumerable: true, get: function () { return run_1.NetworkSchema; } });
|
|
32
|
+
Object.defineProperty(exports, "PredicateNodeSchema", { enumerable: true, get: function () { return run_1.PredicateNodeSchema; } });
|
|
33
|
+
Object.defineProperty(exports, "RecordedTransactionSchema", { enumerable: true, get: function () { return run_1.RecordedTransactionSchema; } });
|
|
34
|
+
Object.defineProperty(exports, "RecordTransactionInputSchema", { enumerable: true, get: function () { return run_1.RecordTransactionInputSchema; } });
|
|
35
|
+
Object.defineProperty(exports, "RevokePolicyInputSchema", { enumerable: true, get: function () { return run_1.RevokePolicyInputSchema; } });
|
|
36
|
+
Object.defineProperty(exports, "SynthesizePolicyInputSchema", { enumerable: true, get: function () { return run_1.SynthesizePolicyInputSchema; } });
|
|
37
|
+
Object.defineProperty(exports, "ToolErrorSchema", { enumerable: true, get: function () { return run_1.ToolErrorSchema; } });
|
|
38
|
+
const zod_1 = require("zod");
|
|
39
|
+
/** Flat ZodRawShape used for the MCP SDK tool registration. The body
|
|
40
|
+
* re-validates against `RecordTransactionInputSchema` so the mutual-exclusion
|
|
41
|
+
* rule still fires (the SDK does not invoke `.refine()` at registration time). */
|
|
42
|
+
exports.RecordTransactionToolShape = {
|
|
43
|
+
hash: zod_1.z.string().min(1).optional(),
|
|
44
|
+
xdr: zod_1.z.string().min(1).optional(),
|
|
45
|
+
network: run_1.NetworkSchema,
|
|
46
|
+
confidenceOverride: zod_1.z.number().min(0).max(1).optional(),
|
|
47
|
+
};
|
|
48
|
+
/** Flat ZodRawShape used for MCP tool registration. Every field is optional
|
|
49
|
+
* so the JSON-Schema the SDK exposes to clients stays permissive; the body
|
|
50
|
+
* re-validates against the strict schema. */
|
|
51
|
+
exports.SynthesizePolicyToolShape = {
|
|
52
|
+
source: zod_1.z.literal('recording').optional(),
|
|
53
|
+
recordedTx: run_1.RecordedTransactionSchema.optional(),
|
|
54
|
+
network: run_1.NetworkSchema.optional(),
|
|
55
|
+
userResponses: run_1.ComposeUserResponsesSchema.optional(),
|
|
56
|
+
confidenceOverride: zod_1.z.object({ threshold: zod_1.z.number().min(0).max(1) }).optional(),
|
|
57
|
+
interpreter: run_1.InterpreterOptionsSchema.optional(),
|
|
58
|
+
// Without this the tool chain has no join. A ProposedPolicy carries
|
|
59
|
+
// `policyDocuments[].encodedPredicate` (canonical ScVal bytes), while
|
|
60
|
+
// `simulate_policy` and `verify_policy` both want the PredicateNode TREE,
|
|
61
|
+
// and the tree is only returned under `explain`. Omitting it here left an
|
|
62
|
+
// MCP client able to synthesize a policy and then unable to prove anything
|
|
63
|
+
// about it - the two checks were reachable only with `predicate: null`,
|
|
64
|
+
// which skips the interpreter predicate entirely.
|
|
65
|
+
explain: zod_1.z.boolean().optional(),
|
|
66
|
+
};
|
|
67
|
+
/** `simulate_policy` and `verify_policy` share their input: the predicate tree
|
|
68
|
+
* (from `synthesize_policy` under `explain`) plus the recording it was
|
|
69
|
+
* synthesised from. */
|
|
70
|
+
const PolicyCheckToolShape = {
|
|
71
|
+
predicate: run_1.PredicateNodeSchema,
|
|
72
|
+
permitTx: run_1.RecordedTransactionSchema,
|
|
73
|
+
validUntilLedger: zod_1.z.number().int().positive().optional(),
|
|
74
|
+
};
|
|
75
|
+
exports.SimulatePolicyToolShape = { ...PolicyCheckToolShape };
|
|
76
|
+
exports.VerifyPolicyToolShape = { ...PolicyCheckToolShape };
|
|
77
|
+
/** Common base for `install_policy` and `revoke_policy`: smartAccount,
|
|
78
|
+
* sourceAccount, optional RPC URL, optional base fee. Both share the
|
|
79
|
+
* same smart-account context, so the SDK-emitted JSON Schema stays
|
|
80
|
+
* identical for those fields. The body re-validates against the strict
|
|
81
|
+
* schemas in `@crediolabs/policy-synth/run`. */
|
|
82
|
+
const SmartAccountToolShape = {
|
|
83
|
+
smartAccount: zod_1.z.string().min(1).optional(),
|
|
84
|
+
sourceAccount: zod_1.z.string().min(1).optional(),
|
|
85
|
+
rpcUrl: zod_1.z.string().url().optional(),
|
|
86
|
+
baseFee: zod_1.z.number().int().positive().optional(),
|
|
87
|
+
};
|
|
88
|
+
/** Flat ZodRawShape for `install_policy`. `rule` is typed as `z.unknown()`
|
|
89
|
+
* at the tool boundary because the rule schema is a discriminated union
|
|
90
|
+
* the SDK does not accept at registration; the body re-validates
|
|
91
|
+
* against `InstallPolicyInputSchema`. `encodedPredicate` /
|
|
92
|
+
* `predicateHash` live INSIDE `rule.policies[].predicateBlobBase64`
|
|
93
|
+
* (the policy already carries the encoded predicate); the run-layer
|
|
94
|
+
* extracts them from there. */
|
|
95
|
+
exports.InstallPolicyToolShape = {
|
|
96
|
+
...SmartAccountToolShape,
|
|
97
|
+
rule: zod_1.z.unknown().optional(),
|
|
98
|
+
installNonce: zod_1.z.number().int().positive().optional(),
|
|
99
|
+
interpreterAddress: zod_1.z.string().optional(),
|
|
100
|
+
};
|
|
101
|
+
/** Flat ZodRawShape for `revoke_policy`. */
|
|
102
|
+
exports.RevokePolicyToolShape = {
|
|
103
|
+
...SmartAccountToolShape,
|
|
104
|
+
ruleId: zod_1.z.number().int().nonnegative().optional(),
|
|
105
|
+
interpreterAddress: zod_1.z.string().optional(),
|
|
106
|
+
};
|
|
107
|
+
/** Flat ZodRawShape for `get_interpreter_info`. `verifyLive` triggers an
|
|
108
|
+
* optional RPC `grammar_version()` call so the caller can verify the
|
|
109
|
+
* deployed contract matches the pin. */
|
|
110
|
+
exports.GetInterpreterInfoToolShape = {
|
|
111
|
+
network: zod_1.z.enum(['mainnet', 'testnet']).optional(),
|
|
112
|
+
verifyLive: zod_1.z.boolean().optional(),
|
|
113
|
+
rpcUrl: zod_1.z.string().url().optional(),
|
|
114
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
/** Build a fresh, stateless MCP server. The caller owns the returned object
|
|
3
|
+
* and connects it to a single transport (stdio or Streamable HTTP). */
|
|
4
|
+
export declare function createMcpServer(): McpServer;
|
|
5
|
+
/** Idempotent registration of the T1 tool set on the given server. */
|
|
6
|
+
export declare function registerTools(server: McpServer): void;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// packages/policy-builder-mcp/src/server.ts
|
|
3
|
+
//
|
|
4
|
+
// Registers the T1 tool surface on a fresh McpServer instance. The
|
|
5
|
+
// registration uses the official MCP SDK's `tool()` API with ZodRawShape
|
|
6
|
+
// schemas (the SDK does not accept ZodEffects / discriminated unions at the
|
|
7
|
+
// tool registration boundary - the known gotcha). The body re-validates
|
|
8
|
+
// against the strict discriminated union in `@crediolabs/policy-synth/run`
|
|
9
|
+
// so wire inputs still fail closed.
|
|
10
|
+
//
|
|
11
|
+
// Stateless: a fresh McpServer is constructed per transport (stdio/HTTP). No
|
|
12
|
+
// shared mutable state across calls; nothing here caches, queues, or holds
|
|
13
|
+
// key material.
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.createMcpServer = createMcpServer;
|
|
16
|
+
exports.registerTools = registerTools;
|
|
17
|
+
const run_1 = require("@crediolabs/policy-synth/run");
|
|
18
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
19
|
+
const schemas_ts_1 = require("./schemas.js");
|
|
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);
|
|
26
|
+
/** Build a fresh, stateless MCP server. The caller owns the returned object
|
|
27
|
+
* and connects it to a single transport (stdio or Streamable HTTP). */
|
|
28
|
+
function createMcpServer() {
|
|
29
|
+
const server = new mcp_js_1.McpServer({ name: 'policy-builder-mcp', version: '0.0.0' }, { capabilities: { tools: {} } });
|
|
30
|
+
registerTools(server);
|
|
31
|
+
return server;
|
|
32
|
+
}
|
|
33
|
+
/** Idempotent registration of the T1 tool set on the given server. */
|
|
34
|
+
function registerTools(server) {
|
|
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 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
|
+
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));
|
|
42
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ToolError, ToolResponse } from '@crediolabs/policy-synth';
|
|
2
|
+
export interface McpToolSuccess<T> {
|
|
3
|
+
isError: false;
|
|
4
|
+
content: Array<{
|
|
5
|
+
type: 'text';
|
|
6
|
+
text: string;
|
|
7
|
+
}>;
|
|
8
|
+
structuredContent: T;
|
|
9
|
+
}
|
|
10
|
+
export interface McpToolError {
|
|
11
|
+
isError: true;
|
|
12
|
+
content: Array<{
|
|
13
|
+
type: 'text';
|
|
14
|
+
text: string;
|
|
15
|
+
}>;
|
|
16
|
+
structuredContent: ToolError;
|
|
17
|
+
}
|
|
18
|
+
export type McpToolResult<T> = McpToolSuccess<T> | McpToolError;
|
|
19
|
+
/** Map a core ToolResponse<T> to the MCP result envelope. The handler in
|
|
20
|
+
* src/server.ts wraps this in the SDK's `{ content, isError }` shape. */
|
|
21
|
+
export declare function mcpResultFromCore<T>(res: ToolResponse<T>): McpToolResult<T>;
|
|
22
|
+
/** Map a core ToolError directly (for inputs that already failed validation
|
|
23
|
+
* inside the handler). */
|
|
24
|
+
export declare function mcpErrorFromCore(err: ToolError): McpToolError;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// packages/policy-builder-mcp/src/tools/result.ts
|
|
3
|
+
//
|
|
4
|
+
// Transport-layer mapping between the core's ToolResponse<T> envelope and the
|
|
5
|
+
// MCP result envelope. This is the ONLY place the two envelopes meet; no
|
|
6
|
+
// business logic lives here. The shapes follow the MCP spec for the
|
|
7
|
+
// `CallToolResult` type:
|
|
8
|
+
//
|
|
9
|
+
// success: { content: [{ type: 'text', text: JSON.stringify(data) }], isError: false }
|
|
10
|
+
// failure: { content: [{ type: 'text', text: JSON.stringify(error) }], isError: true }
|
|
11
|
+
//
|
|
12
|
+
// Both branches carry the structured content as a single JSON-encoded text
|
|
13
|
+
// block so clients that do not parse `structuredContent` still see a usable
|
|
14
|
+
// payload. The shape is intentionally tiny - any extra keys belong in the
|
|
15
|
+
// core, not here.
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.mcpResultFromCore = mcpResultFromCore;
|
|
18
|
+
exports.mcpErrorFromCore = mcpErrorFromCore;
|
|
19
|
+
/** Map a core ToolResponse<T> to the MCP result envelope. The handler in
|
|
20
|
+
* src/server.ts wraps this in the SDK's `{ content, isError }` shape. */
|
|
21
|
+
function mcpResultFromCore(res) {
|
|
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
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Map a core ToolError directly (for inputs that already failed validation
|
|
31
|
+
* inside the handler). */
|
|
32
|
+
function mcpErrorFromCore(err) {
|
|
33
|
+
return {
|
|
34
|
+
isError: true,
|
|
35
|
+
content: [{ type: 'text', text: JSON.stringify(err) }],
|
|
36
|
+
structuredContent: err,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface StartHttpServerOptions {
|
|
2
|
+
port: number;
|
|
3
|
+
host?: string;
|
|
4
|
+
/** Path the server mounts the MCP endpoint at. Default `/mcp`. */
|
|
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;
|
|
12
|
+
}
|
|
13
|
+
export interface RunningHttpServer {
|
|
14
|
+
port: number;
|
|
15
|
+
host: string;
|
|
16
|
+
path: string;
|
|
17
|
+
close: () => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** Stateless Streamable HTTP server. Resolves once the server is listening.
|
|
20
|
+
* The returned handle exposes `close()` for tests + clean shutdown. */
|
|
21
|
+
export declare function startHttpServer(opts: StartHttpServerOptions): Promise<RunningHttpServer>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// packages/policy-builder-mcp/src/transports/http.ts
|
|
3
|
+
//
|
|
4
|
+
// Streamable HTTP transport (hosted). Uses the Node http module directly so
|
|
5
|
+
// the package stays thin - no express / hono dep. We run in STATELESS mode
|
|
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.
|
|
10
|
+
//
|
|
11
|
+
// Single endpoint: POST /mcp (the SDK also accepts GET for SSE streaming, but
|
|
12
|
+
// the T1 surface does not emit server-initiated messages so we omit it).
|
|
13
|
+
// Listens on 127.0.0.1 by default.
|
|
14
|
+
//
|
|
15
|
+
// SECURITY BOUNDARY: the MCP server has NO auth - any request is processed.
|
|
16
|
+
// Binding to a non-loopback interface (e.g. `0.0.0.0`, `::`, an external
|
|
17
|
+
// NIC) would expose that unauthenticated surface to every reachable host
|
|
18
|
+
// (LAN peers, public cloud metadata, the open internet on a misconfigured
|
|
19
|
+
// VPS). The host boundary is therefore FAIL-CLOSED: only loopback
|
|
20
|
+
// (`127.0.0.1`, `::1`, `localhost`) is accepted by default. A caller that
|
|
21
|
+
// KNOWS they want to expose the server MUST pass `allowExternalHost: true`
|
|
22
|
+
// explicitly - the flag is the auditable intent. Test runners and the
|
|
23
|
+
// in-process CLI client do not need it; they bind 127.0.0.1 already.
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.startHttpServer = startHttpServer;
|
|
26
|
+
const node_http_1 = require("node:http");
|
|
27
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
28
|
+
const server_ts_1 = require("../server.js");
|
|
29
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
|
|
30
|
+
/** Stateless Streamable HTTP server. Resolves once the server is listening.
|
|
31
|
+
* The returned handle exposes `close()` for tests + clean shutdown. */
|
|
32
|
+
async function startHttpServer(opts) {
|
|
33
|
+
const host = opts.host ?? '127.0.0.1';
|
|
34
|
+
const path = opts.path ?? '/mcp';
|
|
35
|
+
// Default-deny: refuse to bind a non-loopback host unless the caller has
|
|
36
|
+
// explicitly opted in. The MCP server has no auth, so the only thing
|
|
37
|
+
// standing between this binary and an open attack surface on `0.0.0.0` is
|
|
38
|
+
// this check; we would rather fail loudly here than silently expose it.
|
|
39
|
+
if (!LOOPBACK_HOSTS.has(host) && opts.allowExternalHost !== true) {
|
|
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.`);
|
|
41
|
+
}
|
|
42
|
+
const httpServer = (0, node_http_1.createServer)(async (req, res) => {
|
|
43
|
+
if (!req.url) {
|
|
44
|
+
sendJson(res, 400, { error: 'missing url' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const url = new URL(req.url, `http://${host}`);
|
|
48
|
+
if (url.pathname !== path) {
|
|
49
|
+
sendJson(res, 404, { error: 'not found', path: url.pathname });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (req.method !== 'POST') {
|
|
53
|
+
// The SDK ignores non-POST in stateless mode (no GET/SSE needed in T1).
|
|
54
|
+
sendJson(res, 405, { error: 'method not allowed', method: req.method ?? null });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Reject an over-cap body fast via Content-Length; the streaming reader is
|
|
58
|
+
// the backstop for chunked/unknown-length requests.
|
|
59
|
+
const declaredLength = Number(req.headers['content-length']);
|
|
60
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
|
61
|
+
sendJson(res, 413, { error: 'request body too large' });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Read the JSON-RPC body. The SDK accepts a pre-parsed body, so we parse
|
|
65
|
+
// it here rather than the SDK trying to re-stream the raw req.
|
|
66
|
+
let body;
|
|
67
|
+
try {
|
|
68
|
+
body = await readJsonBody(req);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e instanceof BodyTooLargeError) {
|
|
72
|
+
sendJson(res, 413, { error: 'request body too large' });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
sendJson(res, 400, { error: 'invalid JSON body' });
|
|
76
|
+
}
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// The T1 surface is one request per call. A JSON-RPC batch (array) is
|
|
80
|
+
// rejected explicitly rather than silently dropping the extra calls.
|
|
81
|
+
if (Array.isArray(body)) {
|
|
82
|
+
sendJson(res, 400, {
|
|
83
|
+
error: 'JSON-RPC batch requests are not supported; send one request per call',
|
|
84
|
+
});
|
|
85
|
+
return;
|
|
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
|
+
});
|
|
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);
|
|
114
|
+
// `handleRequest` writes the response and returns once the message has
|
|
115
|
+
// been dispatched. No shared state across calls in stateless mode.
|
|
116
|
+
await transport.handleRequest(req, res, body);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// The SDK normally writes structured errors itself; this is a belt +
|
|
120
|
+
// braces guard so a thrown error does not leave the socket hanging.
|
|
121
|
+
if (res.headersSent) {
|
|
122
|
+
res.end();
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const error = {
|
|
126
|
+
code: 'SYNTHESIS_ERROR',
|
|
127
|
+
message: 'internal server error',
|
|
128
|
+
severity: 'error',
|
|
129
|
+
retryable: false,
|
|
130
|
+
};
|
|
131
|
+
sendJson(res, 500, { error });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
await new Promise((resolve, reject) => {
|
|
136
|
+
httpServer.once('error', reject);
|
|
137
|
+
httpServer.listen(opts.port, host, () => {
|
|
138
|
+
httpServer.off('error', reject);
|
|
139
|
+
resolve();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
port: opts.port,
|
|
144
|
+
host,
|
|
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.
|
|
149
|
+
close: async () => {
|
|
150
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function sendJson(res, status, body) {
|
|
155
|
+
res.statusCode = status;
|
|
156
|
+
res.setHeader('content-type', 'application/json');
|
|
157
|
+
res.end(JSON.stringify(body));
|
|
158
|
+
}
|
|
159
|
+
/** Reject bodies larger than this before buffering the whole payload - a
|
|
160
|
+
* recorded transaction is far smaller, so this only stops abusive requests. */
|
|
161
|
+
const MAX_BODY_BYTES = 1_048_576;
|
|
162
|
+
/** Distinguishes an over-cap body from a malformed one so the handler can send
|
|
163
|
+
* a 413 rather than a misleading 400. */
|
|
164
|
+
class BodyTooLargeError extends Error {
|
|
165
|
+
}
|
|
166
|
+
async function readJsonBody(req) {
|
|
167
|
+
const chunks = [];
|
|
168
|
+
let total = 0;
|
|
169
|
+
for await (const chunk of req) {
|
|
170
|
+
total += chunk.length;
|
|
171
|
+
if (total > MAX_BODY_BYTES)
|
|
172
|
+
throw new BodyTooLargeError('request body too large');
|
|
173
|
+
chunks.push(chunk);
|
|
174
|
+
}
|
|
175
|
+
if (chunks.length === 0)
|
|
176
|
+
return {};
|
|
177
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
178
|
+
if (!raw)
|
|
179
|
+
return {};
|
|
180
|
+
return JSON.parse(raw);
|
|
181
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startStdioServer(): Promise<void>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// packages/policy-builder-mcp/src/transports/stdio.ts
|
|
3
|
+
//
|
|
4
|
+
// stdio transport (Claude Desktop / local agents). The MCP SDK reads JSON-RPC
|
|
5
|
+
// from stdin and writes to stdout. Each process serves ONE client and exits
|
|
6
|
+
// when the client closes the stream. No key custody, no global state; the
|
|
7
|
+
// per-call state lives entirely in the McpServer instance.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.startStdioServer = startStdioServer;
|
|
10
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
11
|
+
const server_ts_1 = require("../server.js");
|
|
12
|
+
async function startStdioServer() {
|
|
13
|
+
const server = (0, server_ts_1.createMcpServer)();
|
|
14
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
15
|
+
await server.connect(transport);
|
|
16
|
+
// The transport owns the process from here; no shutdown signal handling -
|
|
17
|
+
// SIGPIPE / EOF on stdin terminates the loop naturally.
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crediolabs/policy-builder-mcp",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
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/
|
|
18
|
+
"homepage": "https://github.com/untangledfinance/oz-policy-builder#readme",
|
|
19
19
|
"bugs": {
|
|
20
|
-
"url": "https://github.com/untangledfinance/
|
|
20
|
+
"url": "https://github.com/untangledfinance/oz-policy-builder/issues"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"stellar",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"types": "./dist/src/index.d.ts",
|
|
38
38
|
"bun": "./src/index.ts",
|
|
39
39
|
"import": "./dist/src/index.js",
|
|
40
|
+
"require": "./dist-cjs/src/index.js",
|
|
40
41
|
"default": "./dist/src/index.js"
|
|
41
42
|
},
|
|
42
43
|
"./package.json": "./package.json"
|
|
@@ -46,6 +47,7 @@
|
|
|
46
47
|
},
|
|
47
48
|
"files": [
|
|
48
49
|
"dist",
|
|
50
|
+
"dist-cjs",
|
|
49
51
|
"src",
|
|
50
52
|
"!src/**/*.test.ts"
|
|
51
53
|
],
|
|
@@ -54,13 +56,15 @@
|
|
|
54
56
|
},
|
|
55
57
|
"scripts": {
|
|
56
58
|
"test": "bun test",
|
|
57
|
-
"build": "tsc -p tsconfig.build.json",
|
|
59
|
+
"build": "tsc -p tsconfig.build.json && tsc -p tsconfig.build.cjs.json && node scripts/write-cjs-package-json.cjs",
|
|
60
|
+
"build:esm": "tsc -p tsconfig.build.json",
|
|
61
|
+
"build:cjs": "tsc -p tsconfig.build.cjs.json && node scripts/write-cjs-package-json.cjs",
|
|
58
62
|
"prepublishOnly": "bun run build && bun test",
|
|
59
63
|
"prepack": "bun run build"
|
|
60
64
|
},
|
|
61
65
|
"dependencies": {
|
|
62
|
-
"@crediolabs/policy-synth": "0.
|
|
63
|
-
"@modelcontextprotocol/sdk": "1.
|
|
66
|
+
"@crediolabs/policy-synth": "0.3.0",
|
|
67
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
64
68
|
"@stellar/stellar-sdk": "14.4.0",
|
|
65
69
|
"zod": "3.25.76"
|
|
66
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
|
|
80
|
-
*
|
|
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.
|
|
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
|
-
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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,
|
|
@@ -167,15 +138,6 @@ export const RevokePolicyToolShape = {
|
|
|
167
138
|
interpreterAddress: z.string().optional(),
|
|
168
139
|
} as const
|
|
169
140
|
|
|
170
|
-
/** Flat ZodRawShape for `merge_policy`: the tightening remedy when two rules
|
|
171
|
-
* our interpreter polices can serve the same calls. Two steps, in order. */
|
|
172
|
-
export const MergePolicyToolShape = {
|
|
173
|
-
...SmartAccountToolShape,
|
|
174
|
-
ruleId: z.number().int().nonnegative(),
|
|
175
|
-
incomingPredicateBlobBase64: z.string().min(1),
|
|
176
|
-
step: z.enum(['detach', 'reinstall']),
|
|
177
|
-
} as const
|
|
178
|
-
|
|
179
141
|
/** Flat ZodRawShape for `get_interpreter_info`. `verifyLive` triggers an
|
|
180
142
|
* optional RPC `grammar_version()` call so the caller can verify the
|
|
181
143
|
* deployed contract matches the pin. */
|