@crediolabs/policy-builder-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/server.d.ts +2 -0
- package/dist/bin/server.js +24 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.js +7 -0
- package/dist/src/schemas.d.ts +2297 -0
- package/dist/src/schemas.js +221 -0
- package/dist/src/server.d.ts +6 -0
- package/dist/src/server.js +38 -0
- package/dist/src/tools/result.d.ts +24 -0
- package/dist/src/tools/result.js +35 -0
- package/dist/src/tools/run.d.ts +15 -0
- package/dist/src/tools/run.js +97 -0
- package/dist/src/transports/http.d.ts +15 -0
- package/dist/src/transports/http.js +137 -0
- package/dist/src/transports/stdio.d.ts +1 -0
- package/dist/src/transports/stdio.js +15 -0
- package/package.json +42 -0
- package/src/index.ts +21 -0
- package/src/schemas.ts +249 -0
- package/src/server.ts +56 -0
- package/src/tools/result.ts +53 -0
- package/src/tools/run.ts +133 -0
- package/src/transports/http.ts +155 -0
- package/src/transports/stdio.ts +17 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// apps/policy-builder-mcp/src/schemas.ts
|
|
2
|
+
//
|
|
3
|
+
// Zod schemas mirroring the policy-synth core domain types. These are the
|
|
4
|
+
// public input / output shapes exposed over MCP and the CLI. They are kept
|
|
5
|
+
// hand-written (rather than derived) because the MCP SDK needs a runtime
|
|
6
|
+
// Zod object at the transport boundary; a drift test asserts they stay in
|
|
7
|
+
// step with the TS source of truth.
|
|
8
|
+
//
|
|
9
|
+
// i128 amounts and other large integers are carried as base-10 decimal strings
|
|
10
|
+
// end-to-end (no JS number coercion). Networks are pinned to the same closed
|
|
11
|
+
// set the core defines. The discriminated union on `source` exposes BOTH
|
|
12
|
+
// synthesize_policy front-ends through a single tool input.
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
/** Soroban `valid_until` is a u32 ledger sequence; a value above this cannot be
|
|
15
|
+
* installed on-chain, so reject it at the boundary (fail-closed). */
|
|
16
|
+
const U32_MAX = 4294967295;
|
|
17
|
+
/** Upper bound on top-level invocations in a recorded transaction. A real
|
|
18
|
+
* Stellar tx caps operations well below this; the bound stops a hand-crafted
|
|
19
|
+
* payload from turning one request into an unbounded synthesis (DoS). */
|
|
20
|
+
const MAX_INVOCATIONS = 512;
|
|
21
|
+
export const NetworkSchema = z.enum(['mainnet', 'testnet']);
|
|
22
|
+
/** ScVal subset - normalised subset the synth consumes. Mirrors
|
|
23
|
+
* `ScVal` in packages/policy-synth/src/types.ts. */
|
|
24
|
+
export const ScValSchema = z.lazy(() => z.union([
|
|
25
|
+
z.object({ type: z.literal('address'), value: z.string() }),
|
|
26
|
+
// i128 is SIGNED: real events carry negatives (e.g. a fee-adjustment/refund),
|
|
27
|
+
// so the recorder's own output must round-trip through this schema. u64/u32
|
|
28
|
+
// are unsigned and stay non-negative.
|
|
29
|
+
z.object({ type: z.literal('i128'), value: z.string().regex(/^-?[0-9]+$/) }),
|
|
30
|
+
z.object({ type: z.literal('u64'), value: z.string().regex(/^[0-9]+$/) }),
|
|
31
|
+
z.object({ type: z.literal('u32'), value: z.string().regex(/^[0-9]+$/) }),
|
|
32
|
+
z.object({ type: z.literal('symbol'), value: z.string() }),
|
|
33
|
+
z.object({ type: z.literal('vec'), value: z.array(ScValSchema) }),
|
|
34
|
+
z.object({ type: z.literal('bytes'), value: z.string() }),
|
|
35
|
+
z.object({ type: z.literal('other'), value: z.string() }),
|
|
36
|
+
]));
|
|
37
|
+
/** ContractInvocation mirrors the core. Annotated with an explicit
|
|
38
|
+
* `z.ZodType<unknown>` (like ScValSchema above) so the self-referential
|
|
39
|
+
* `subInvocations` field does not trip TS's circular type inference. */
|
|
40
|
+
export const ContractInvocationSchema = z.object({
|
|
41
|
+
contract: z.string(),
|
|
42
|
+
fn: z.string(),
|
|
43
|
+
args: z.array(ScValSchema),
|
|
44
|
+
subInvocations: z.array(z.lazy(() => ContractInvocationSchema)),
|
|
45
|
+
});
|
|
46
|
+
export const TokenMovementSchema = z.object({
|
|
47
|
+
token: z.string(),
|
|
48
|
+
from: z.string(),
|
|
49
|
+
to: z.string(),
|
|
50
|
+
// The recorder reads the amount straight from the signed i128 event value
|
|
51
|
+
// (record/movements.ts readAmount), so a non-standard token that emits a
|
|
52
|
+
// negative transfer/mint/burn amount round-trips as a negative string. Mirror
|
|
53
|
+
// that here; the synth gate, not the wire schema, decides what to do with it.
|
|
54
|
+
amount: z.string().regex(/^-?[0-9]+$/),
|
|
55
|
+
});
|
|
56
|
+
export const OnChainEventSchema = z.object({
|
|
57
|
+
contract: z.string(),
|
|
58
|
+
topics: z.array(z.string()),
|
|
59
|
+
data: ScValSchema,
|
|
60
|
+
});
|
|
61
|
+
export const ParseConfidenceSchema = z.object({
|
|
62
|
+
overall: z.number().min(0).max(1),
|
|
63
|
+
knownContracts: z.array(z.string()),
|
|
64
|
+
unknownContracts: z.array(z.object({
|
|
65
|
+
contract: z.string(),
|
|
66
|
+
reason: z.enum(['no-abi', 'version-mismatch', 'opaque-result']),
|
|
67
|
+
})),
|
|
68
|
+
opaqueScVals: z.array(z.object({ path: z.string(), type: z.string() })),
|
|
69
|
+
thresholdUsed: z.number().min(0).max(1),
|
|
70
|
+
});
|
|
71
|
+
/** RecordedTransaction mirrors the core RecordedTransaction. The output shape
|
|
72
|
+
* is referenced by name in the tool result structured content; we deliberately
|
|
73
|
+
* type it loosely (`z.unknown()`) on the success path so the core remains the
|
|
74
|
+
* single source of truth for the wire payload. */
|
|
75
|
+
export const RecordedTransactionSchema = z
|
|
76
|
+
.object({
|
|
77
|
+
network: NetworkSchema,
|
|
78
|
+
signers: z.array(z.string()),
|
|
79
|
+
invocations: z.array(ContractInvocationSchema).max(MAX_INVOCATIONS),
|
|
80
|
+
tokenMovements: z.array(TokenMovementSchema),
|
|
81
|
+
events: z.array(OnChainEventSchema),
|
|
82
|
+
authEntries: z.array(z.unknown()),
|
|
83
|
+
ledgerSequence: z.number().int().nonnegative(),
|
|
84
|
+
fetchedAt: z.number().int().nonnegative(),
|
|
85
|
+
parseConfidence: ParseConfidenceSchema,
|
|
86
|
+
sourceAccount: z.string(),
|
|
87
|
+
})
|
|
88
|
+
.passthrough();
|
|
89
|
+
/** MandateSpec mirrors the core MandateSpec. The deterministic Mandate
|
|
90
|
+
* front-end needs no parseConfidence; the tool adapter injects the full
|
|
91
|
+
* confidence after synthesis so the orchestrator can compare. */
|
|
92
|
+
export const MandateSpecSchema = z
|
|
93
|
+
.object({
|
|
94
|
+
chain: z.literal('stellar'),
|
|
95
|
+
contract: z.string(),
|
|
96
|
+
method: z.string().optional(),
|
|
97
|
+
spendingLimit: z
|
|
98
|
+
.object({
|
|
99
|
+
token: z.string(),
|
|
100
|
+
limit: z.string().regex(/^[0-9]+$/),
|
|
101
|
+
windowSeconds: z.number().int().positive(),
|
|
102
|
+
})
|
|
103
|
+
.optional(),
|
|
104
|
+
// A threshold of 0 means "0 approvals", which is not a real M-of-N gate.
|
|
105
|
+
approvalThreshold: z.number().int().positive().optional(),
|
|
106
|
+
recipients: z.array(z.string()).optional(),
|
|
107
|
+
expiry: z
|
|
108
|
+
.object({
|
|
109
|
+
validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
|
|
110
|
+
validUntilUnixSeconds: z.number().int().positive().optional(),
|
|
111
|
+
})
|
|
112
|
+
.optional(),
|
|
113
|
+
})
|
|
114
|
+
.passthrough();
|
|
115
|
+
/** ComposeUserResponses mirrors the core. */
|
|
116
|
+
export const ComposeUserResponsesSchema = z
|
|
117
|
+
.object({
|
|
118
|
+
windowSeconds: z.number().int().positive().optional(),
|
|
119
|
+
validUntilLedger: z.number().int().positive().max(U32_MAX).optional(),
|
|
120
|
+
limitAmount: z
|
|
121
|
+
.string()
|
|
122
|
+
.regex(/^[0-9]+$/)
|
|
123
|
+
.optional(),
|
|
124
|
+
invocationLimit: z.number().int().positive().optional(),
|
|
125
|
+
})
|
|
126
|
+
.passthrough();
|
|
127
|
+
/** OzAdapterConfig - the per-network OZ built-in instance addresses. */
|
|
128
|
+
export const OzAdapterConfigSchema = z.object({
|
|
129
|
+
network: NetworkSchema,
|
|
130
|
+
instances: z.object({
|
|
131
|
+
spending_limit: z.string(),
|
|
132
|
+
simple_threshold: z.string(),
|
|
133
|
+
weighted_threshold: z.string(),
|
|
134
|
+
}),
|
|
135
|
+
});
|
|
136
|
+
// ===== record_transaction =====
|
|
137
|
+
export const RecordTransactionInputSchema = z
|
|
138
|
+
.object({
|
|
139
|
+
hash: z.string().min(1).optional(),
|
|
140
|
+
xdr: z.string().min(1).optional(),
|
|
141
|
+
network: NetworkSchema,
|
|
142
|
+
confidenceOverride: z.number().min(0).max(1).optional(),
|
|
143
|
+
})
|
|
144
|
+
.refine((v) => !(v.hash && v.xdr), {
|
|
145
|
+
message: 'provide exactly one of `hash` or `xdr`, not both',
|
|
146
|
+
})
|
|
147
|
+
.refine((v) => Boolean(v.hash) || Boolean(v.xdr), {
|
|
148
|
+
message: 'one of `hash` or `xdr` is required',
|
|
149
|
+
});
|
|
150
|
+
/** Flat ZodRawShape used for the MCP SDK tool registration. The body
|
|
151
|
+
* re-validates against `RecordTransactionInputSchema` so the mutual-exclusion
|
|
152
|
+
* rule still fires (the SDK does not invoke `.refine()` at registration time). */
|
|
153
|
+
export const RecordTransactionToolShape = {
|
|
154
|
+
hash: z.string().min(1).optional(),
|
|
155
|
+
xdr: z.string().min(1).optional(),
|
|
156
|
+
network: NetworkSchema,
|
|
157
|
+
confidenceOverride: z.number().min(0).max(1).optional(),
|
|
158
|
+
};
|
|
159
|
+
// ===== synthesize_policy =====
|
|
160
|
+
//
|
|
161
|
+
// Discriminated union on `source` exposes BOTH front-ends through ONE tool.
|
|
162
|
+
// - `source: 'mandate'` -> calls synthesizeFromMandate
|
|
163
|
+
// - `source: 'recording'` -> calls synthesizeFromRecording
|
|
164
|
+
//
|
|
165
|
+
// The MCP SDK's `tool()` API only accepts a flat ZodRawShape (named
|
|
166
|
+
// properties keyed by Zod schemas), so we ALSO export a flat shape used at
|
|
167
|
+
// the transport boundary. The discriminated union is the strict body-side
|
|
168
|
+
// validator; the tool handler runs BOTH and treats the flat shape as the
|
|
169
|
+
// friendly wire contract.
|
|
170
|
+
export const SynthesizePolicyMandateInputSchema = z.object({
|
|
171
|
+
source: z.literal('mandate'),
|
|
172
|
+
mandate: MandateSpecSchema,
|
|
173
|
+
ozConfig: OzAdapterConfigSchema.optional(),
|
|
174
|
+
});
|
|
175
|
+
export const SynthesizePolicyRecordingInputSchema = z.object({
|
|
176
|
+
source: z.literal('recording'),
|
|
177
|
+
recordedTx: RecordedTransactionSchema,
|
|
178
|
+
network: NetworkSchema,
|
|
179
|
+
userResponses: ComposeUserResponsesSchema.optional(),
|
|
180
|
+
confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
|
|
181
|
+
ozConfig: OzAdapterConfigSchema.optional(),
|
|
182
|
+
});
|
|
183
|
+
export const SynthesizePolicyInputSchema = z.discriminatedUnion('source', [
|
|
184
|
+
SynthesizePolicyMandateInputSchema,
|
|
185
|
+
SynthesizePolicyRecordingInputSchema,
|
|
186
|
+
]);
|
|
187
|
+
/** Flat ZodRawShape used for MCP tool registration. Every field is optional
|
|
188
|
+
* so the JSON-Schema the SDK exposes to clients does not forbid either
|
|
189
|
+
* front-end; the body re-validates against the discriminated union. */
|
|
190
|
+
export const SynthesizePolicyToolShape = {
|
|
191
|
+
source: z.enum(['mandate', 'recording']).optional(),
|
|
192
|
+
mandate: MandateSpecSchema.optional(),
|
|
193
|
+
recordedTx: RecordedTransactionSchema.optional(),
|
|
194
|
+
network: NetworkSchema.optional(),
|
|
195
|
+
userResponses: ComposeUserResponsesSchema.optional(),
|
|
196
|
+
confidenceOverride: z.object({ threshold: z.number().min(0).max(1) }).optional(),
|
|
197
|
+
ozConfig: OzAdapterConfigSchema.optional(),
|
|
198
|
+
};
|
|
199
|
+
// ===== Error envelope (canonical) =====
|
|
200
|
+
//
|
|
201
|
+
// Mirrors ToolError from packages/policy-synth/src/errors.ts. We use a
|
|
202
|
+
// `z.string()` for `code` (not an enum) because the core's ErrorCode union
|
|
203
|
+
// evolves over time; the transport contract only promises a string code the
|
|
204
|
+
// caller can dispatch on. A drift test asserts the canonical codes still
|
|
205
|
+
// pass through unchanged.
|
|
206
|
+
export const ToolErrorSchema = z
|
|
207
|
+
.object({
|
|
208
|
+
code: z.string(),
|
|
209
|
+
message: z.string(),
|
|
210
|
+
severity: z.enum(['info', 'warning', 'error', 'fatal']),
|
|
211
|
+
retryable: z.boolean(),
|
|
212
|
+
remediation: z
|
|
213
|
+
.object({
|
|
214
|
+
toolCall: z.object({ name: z.string(), args: z.record(z.unknown()) }).optional(),
|
|
215
|
+
userQuestion: z.object({ code: z.string(), question: z.string() }).optional(),
|
|
216
|
+
docsUrl: z.string().optional(),
|
|
217
|
+
})
|
|
218
|
+
.optional(),
|
|
219
|
+
details: z.unknown().optional(),
|
|
220
|
+
})
|
|
221
|
+
.passthrough();
|
|
@@ -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,38 @@
|
|
|
1
|
+
// apps/policy-builder-mcp/src/server.ts
|
|
2
|
+
//
|
|
3
|
+
// Registers the T1 tool surface on a fresh McpServer instance. The
|
|
4
|
+
// registration uses the official MCP SDK's `tool()` API with ZodRawShape
|
|
5
|
+
// schemas (the SDK does not accept ZodEffects / discriminated unions at the
|
|
6
|
+
// tool registration boundary - the known gotcha). The body re-validates
|
|
7
|
+
// against the strict discriminated union in src/tools/run.ts so wire inputs
|
|
8
|
+
// still fail closed.
|
|
9
|
+
//
|
|
10
|
+
// Stateless: a fresh McpServer is constructed per transport (stdio/HTTP). No
|
|
11
|
+
// shared mutable state across calls; nothing here caches, queues, or holds
|
|
12
|
+
// key material.
|
|
13
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
14
|
+
import { RecordTransactionToolShape, SynthesizePolicyToolShape } from "./schemas.js";
|
|
15
|
+
import { mcpResultFromCore } from "./tools/result.js";
|
|
16
|
+
import { runRecordTransaction, runSynthesizePolicy } from "./tools/run.js";
|
|
17
|
+
/** Build a fresh, stateless MCP server. The caller owns the returned object
|
|
18
|
+
* and connects it to a single transport (stdio or Streamable HTTP). */
|
|
19
|
+
export function createMcpServer() {
|
|
20
|
+
const server = new McpServer({ name: 'policy-builder-mcp', version: '0.0.0' }, { capabilities: { tools: {} } });
|
|
21
|
+
registerTools(server);
|
|
22
|
+
return server;
|
|
23
|
+
}
|
|
24
|
+
/** Idempotent registration of the T1 tool set on the given server. */
|
|
25
|
+
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
|
+
}
|
|
@@ -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,35 @@
|
|
|
1
|
+
// apps/policy-builder-mcp/src/tools/result.ts
|
|
2
|
+
//
|
|
3
|
+
// Transport-layer mapping between the core's ToolResponse<T> envelope and the
|
|
4
|
+
// MCP result envelope. This is the ONLY place the two envelopes meet; no
|
|
5
|
+
// business logic lives here. The shapes follow the MCP spec for the
|
|
6
|
+
// `CallToolResult` type:
|
|
7
|
+
//
|
|
8
|
+
// success: { content: [{ type: 'text', text: JSON.stringify(data) }], isError: false }
|
|
9
|
+
// failure: { content: [{ type: 'text', text: JSON.stringify(error) }], isError: true }
|
|
10
|
+
//
|
|
11
|
+
// Both branches carry the structured content as a single JSON-encoded text
|
|
12
|
+
// block so clients that do not parse `structuredContent` still see a usable
|
|
13
|
+
// payload. The shape is intentionally tiny - any extra keys belong in the
|
|
14
|
+
// core, not here.
|
|
15
|
+
/** Map a core ToolResponse<T> to the MCP result envelope. The handler in
|
|
16
|
+
* src/server.ts wraps this in the SDK's `{ content, isError }` shape. */
|
|
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);
|
|
26
|
+
}
|
|
27
|
+
/** Map a core ToolError directly (for inputs that already failed validation
|
|
28
|
+
* inside the handler). */
|
|
29
|
+
export function mcpErrorFromCore(err) {
|
|
30
|
+
return {
|
|
31
|
+
isError: true,
|
|
32
|
+
content: [{ type: 'text', text: JSON.stringify(err) }],
|
|
33
|
+
structuredContent: err,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type ProposedPolicy, type RecordedTransaction, type ToolResponse } from '@crediolabs/policy-synth';
|
|
2
|
+
import { type RecordTransactionInput, type SynthesizePolicyInput } from '../schemas.ts';
|
|
3
|
+
export type RunRecordTransactionInput = RecordTransactionInput;
|
|
4
|
+
export type RunSynthesizePolicyInput = SynthesizePolicyInput;
|
|
5
|
+
/** `record_transaction` body - wraps `recordTransaction`. The tool input
|
|
6
|
+
* matches the core RecordInput minus the injected `fetcher` (the transport
|
|
7
|
+
* layer does not own the RPC). Returns the core ToolResponse unchanged. */
|
|
8
|
+
export declare function runRecordTransaction(raw: unknown): Promise<ToolResponse<RecordedTransaction>>;
|
|
9
|
+
/** `synthesize_policy` body - discriminated union on `source`:
|
|
10
|
+
* - `mandate` -> synthesizeFromMandate
|
|
11
|
+
* - `recording` -> synthesizeFromRecording
|
|
12
|
+
* Exposing BOTH front-ends through ONE tool keeps the MCP surface tiny while
|
|
13
|
+
* letting the agent pick the deterministic or the inferred path. The CLI
|
|
14
|
+
* routes the same way. */
|
|
15
|
+
export declare function runSynthesizePolicy(raw: unknown): Promise<ToolResponse<ProposedPolicy>>;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// apps/policy-builder-mcp/src/tools/run.ts
|
|
2
|
+
//
|
|
3
|
+
// Tool bodies for `record_transaction` and `synthesize_policy`. Each body is a
|
|
4
|
+
// THIN adapter over the pure core:
|
|
5
|
+
//
|
|
6
|
+
// 1. Re-validate the parsed input via Zod. This is a defence-in-depth check -
|
|
7
|
+
// the SDK has already parsed it through the registered schema, but we never
|
|
8
|
+
// want a tool body to throw on garbage (the SDK treats uncaught throws as
|
|
9
|
+
// transport errors and the agent loses the machine-readable ToolError).
|
|
10
|
+
// 2. Dispatch to the matching core entry point with the minimum required
|
|
11
|
+
// inputs.
|
|
12
|
+
// 3. Return the core's ToolResponse<T> unchanged. The server layer maps it
|
|
13
|
+
// to the MCP envelope; nothing here knows about MCP.
|
|
14
|
+
//
|
|
15
|
+
// No business logic. No retries. No session state. The same call shape can
|
|
16
|
+
// drive the CLI (which calls into the same core directly without MCP).
|
|
17
|
+
import { placeholderOzConfig, recordTransaction, synthesizeFromMandate, synthesizeFromRecording, } from '@crediolabs/policy-synth';
|
|
18
|
+
import { RecordTransactionInputSchema, SynthesizePolicyInputSchema, } from "../schemas.js";
|
|
19
|
+
/** `record_transaction` body - wraps `recordTransaction`. The tool input
|
|
20
|
+
* matches the core RecordInput minus the injected `fetcher` (the transport
|
|
21
|
+
* layer does not own the RPC). Returns the core ToolResponse unchanged. */
|
|
22
|
+
export async function runRecordTransaction(raw) {
|
|
23
|
+
const parsed = RecordTransactionInputSchema.safeParse(raw);
|
|
24
|
+
if (!parsed.success) {
|
|
25
|
+
return {
|
|
26
|
+
ok: false,
|
|
27
|
+
error: validationError('record_transaction', parsed.error.issues),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const input = parsed.data;
|
|
31
|
+
// Strip the wire-only `confidenceOverride` and pass the rest straight through.
|
|
32
|
+
const coreInput = {
|
|
33
|
+
network: input.network,
|
|
34
|
+
...(input.hash !== undefined ? { hash: input.hash } : {}),
|
|
35
|
+
...(input.xdr !== undefined ? { xdr: input.xdr } : {}),
|
|
36
|
+
...(input.confidenceOverride !== undefined
|
|
37
|
+
? { confidenceOverride: input.confidenceOverride }
|
|
38
|
+
: {}),
|
|
39
|
+
};
|
|
40
|
+
return recordTransaction(coreInput);
|
|
41
|
+
}
|
|
42
|
+
/** `synthesize_policy` body - discriminated union on `source`:
|
|
43
|
+
* - `mandate` -> synthesizeFromMandate
|
|
44
|
+
* - `recording` -> synthesizeFromRecording
|
|
45
|
+
* Exposing BOTH front-ends through ONE tool keeps the MCP surface tiny while
|
|
46
|
+
* letting the agent pick the deterministic or the inferred path. The CLI
|
|
47
|
+
* routes the same way. */
|
|
48
|
+
export async function runSynthesizePolicy(raw) {
|
|
49
|
+
const parsed = SynthesizePolicyInputSchema.safeParse(raw);
|
|
50
|
+
if (!parsed.success) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
error: validationError('synthesize_policy', parsed.error.issues),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const input = parsed.data;
|
|
57
|
+
const ozConfig = resolveOzConfig(input);
|
|
58
|
+
if (input.source === 'mandate') {
|
|
59
|
+
// Zod's optional fields widen to `T | undefined`, which the core's
|
|
60
|
+
// exact-optional MandateSpec rejects; the schema already validated the
|
|
61
|
+
// shape, so assert it (same pattern as the recordedTx cast below).
|
|
62
|
+
return synthesizeFromMandate(input.mandate, ozConfig);
|
|
63
|
+
}
|
|
64
|
+
// recording source
|
|
65
|
+
const recorded = input.recordedTx;
|
|
66
|
+
return synthesizeFromRecording(recorded, {
|
|
67
|
+
network: input.network,
|
|
68
|
+
...(input.userResponses !== undefined ? { userResponses: input.userResponses } : {}),
|
|
69
|
+
...(input.confidenceOverride !== undefined
|
|
70
|
+
? { confidenceOverride: input.confidenceOverride }
|
|
71
|
+
: {}),
|
|
72
|
+
}, ozConfig);
|
|
73
|
+
}
|
|
74
|
+
function resolveOzConfig(input) {
|
|
75
|
+
if (input.ozConfig)
|
|
76
|
+
return input.ozConfig;
|
|
77
|
+
// The mandate path is network-agnostic; fall back to mainnet so the
|
|
78
|
+
// placeholder OZ instance addresses are deterministic.
|
|
79
|
+
return placeholderOzConfig('mainnet');
|
|
80
|
+
}
|
|
81
|
+
/** Build a canonical ToolError for a Zod validation failure. The remediation
|
|
82
|
+
* hint points the agent back at the right tool with an empty arg bag - the
|
|
83
|
+
* tool name IS the machine-readable hint. */
|
|
84
|
+
function validationError(toolName, issues) {
|
|
85
|
+
const code = toolName === 'record_transaction' ? 'RECORDING_FAILED' : 'SYNTHESIS_ERROR';
|
|
86
|
+
return {
|
|
87
|
+
code,
|
|
88
|
+
message: `${toolName}: invalid input: ${issues
|
|
89
|
+
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
|
|
90
|
+
.join('; ')}`,
|
|
91
|
+
severity: 'error',
|
|
92
|
+
retryable: false,
|
|
93
|
+
remediation: {
|
|
94
|
+
toolCall: { name: toolName, args: {} },
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
|
7
|
+
export interface RunningHttpServer {
|
|
8
|
+
port: number;
|
|
9
|
+
host: string;
|
|
10
|
+
path: string;
|
|
11
|
+
close: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/** Stateless Streamable HTTP server. Resolves once the server is listening.
|
|
14
|
+
* The returned handle exposes `close()` for tests + clean shutdown. */
|
|
15
|
+
export declare function startHttpServer(opts: StartHttpServerOptions): Promise<RunningHttpServer>;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// apps/policy-builder-mcp/src/transports/http.ts
|
|
2
|
+
//
|
|
3
|
+
// Streamable HTTP transport (hosted). Uses the Node http module directly so
|
|
4
|
+
// the package stays thin - no express / hono dep. We run in STATELESS mode
|
|
5
|
+
// (sessionIdGenerator: undefined) so each POST /mcp is its own transaction:
|
|
6
|
+
// this matches the brief's "stateless across calls" invariant.
|
|
7
|
+
//
|
|
8
|
+
// Single endpoint: POST /mcp (the SDK also accepts GET for SSE streaming, but
|
|
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.
|
|
11
|
+
import { createServer } from 'node:http';
|
|
12
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
13
|
+
import { createMcpServer } from "../server.js";
|
|
14
|
+
/** Stateless Streamable HTTP server. Resolves once the server is listening.
|
|
15
|
+
* The returned handle exposes `close()` for tests + clean shutdown. */
|
|
16
|
+
export async function startHttpServer(opts) {
|
|
17
|
+
const host = opts.host ?? '127.0.0.1';
|
|
18
|
+
const path = opts.path ?? '/mcp';
|
|
19
|
+
const server = createMcpServer();
|
|
20
|
+
// One transport per server (the SDK reuses the transport for every request
|
|
21
|
+
// in stateless mode). We connect it once at startup and reuse it.
|
|
22
|
+
const transport = new StreamableHTTPServerTransport({
|
|
23
|
+
sessionIdGenerator: undefined,
|
|
24
|
+
});
|
|
25
|
+
await server.connect(transport);
|
|
26
|
+
const httpServer = createServer(async (req, res) => {
|
|
27
|
+
if (!req.url) {
|
|
28
|
+
sendJson(res, 400, { error: 'missing url' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const url = new URL(req.url, `http://${host}`);
|
|
32
|
+
if (url.pathname !== path) {
|
|
33
|
+
sendJson(res, 404, { error: 'not found', path: url.pathname });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (req.method !== 'POST') {
|
|
37
|
+
// The SDK ignores non-POST in stateless mode (no GET/SSE needed in T1).
|
|
38
|
+
sendJson(res, 405, { error: 'method not allowed', method: req.method ?? null });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// Reject an over-cap body fast via Content-Length; the streaming reader is
|
|
42
|
+
// the backstop for chunked/unknown-length requests.
|
|
43
|
+
const declaredLength = Number(req.headers['content-length']);
|
|
44
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) {
|
|
45
|
+
sendJson(res, 413, { error: 'request body too large' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
// Read the JSON-RPC body. The SDK accepts a pre-parsed body, so we parse
|
|
49
|
+
// it here rather than the SDK trying to re-stream the raw req.
|
|
50
|
+
let body;
|
|
51
|
+
try {
|
|
52
|
+
body = await readJsonBody(req);
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
if (e instanceof BodyTooLargeError) {
|
|
56
|
+
sendJson(res, 413, { error: 'request body too large' });
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
sendJson(res, 400, { error: 'invalid JSON body' });
|
|
60
|
+
}
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
// The T1 surface is one request per call. A JSON-RPC batch (array) is
|
|
64
|
+
// rejected explicitly rather than silently dropping the extra calls.
|
|
65
|
+
if (Array.isArray(body)) {
|
|
66
|
+
sendJson(res, 400, {
|
|
67
|
+
error: 'JSON-RPC batch requests are not supported; send one request per call',
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
// `handleRequest` writes the response and returns once the message has
|
|
73
|
+
// been dispatched. No shared state across calls in stateless mode.
|
|
74
|
+
await transport.handleRequest(req, res, body);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// The SDK normally writes structured errors itself; this is a belt +
|
|
78
|
+
// braces guard so a thrown error does not leave the socket hanging.
|
|
79
|
+
if (!res.headersSent) {
|
|
80
|
+
const error = {
|
|
81
|
+
code: 'SYNTHESIS_ERROR',
|
|
82
|
+
message: 'internal server error',
|
|
83
|
+
severity: 'error',
|
|
84
|
+
retryable: false,
|
|
85
|
+
};
|
|
86
|
+
sendJson(res, 500, { error });
|
|
87
|
+
}
|
|
88
|
+
else
|
|
89
|
+
res.end();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
httpServer.once('error', reject);
|
|
94
|
+
httpServer.listen(opts.port, host, () => {
|
|
95
|
+
httpServer.off('error', reject);
|
|
96
|
+
resolve();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
return {
|
|
100
|
+
port: opts.port,
|
|
101
|
+
host,
|
|
102
|
+
path,
|
|
103
|
+
close: async () => {
|
|
104
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
105
|
+
await transport.close().catch(() => { });
|
|
106
|
+
await server.close().catch(() => { });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function sendJson(res, status, body) {
|
|
111
|
+
res.statusCode = status;
|
|
112
|
+
res.setHeader('content-type', 'application/json');
|
|
113
|
+
res.end(JSON.stringify(body));
|
|
114
|
+
}
|
|
115
|
+
/** Reject bodies larger than this before buffering the whole payload - a
|
|
116
|
+
* recorded transaction is far smaller, so this only stops abusive requests. */
|
|
117
|
+
const MAX_BODY_BYTES = 1_048_576;
|
|
118
|
+
/** Distinguishes an over-cap body from a malformed one so the handler can send
|
|
119
|
+
* a 413 rather than a misleading 400. */
|
|
120
|
+
class BodyTooLargeError extends Error {
|
|
121
|
+
}
|
|
122
|
+
async function readJsonBody(req) {
|
|
123
|
+
const chunks = [];
|
|
124
|
+
let total = 0;
|
|
125
|
+
for await (const chunk of req) {
|
|
126
|
+
total += chunk.length;
|
|
127
|
+
if (total > MAX_BODY_BYTES)
|
|
128
|
+
throw new BodyTooLargeError('request body too large');
|
|
129
|
+
chunks.push(chunk);
|
|
130
|
+
}
|
|
131
|
+
if (chunks.length === 0)
|
|
132
|
+
return {};
|
|
133
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
134
|
+
if (!raw)
|
|
135
|
+
return {};
|
|
136
|
+
return JSON.parse(raw);
|
|
137
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function startStdioServer(): Promise<void>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// apps/policy-builder-mcp/src/transports/stdio.ts
|
|
2
|
+
//
|
|
3
|
+
// stdio transport (Claude Desktop / local agents). The MCP SDK reads JSON-RPC
|
|
4
|
+
// from stdin and writes to stdout. Each process serves ONE client and exits
|
|
5
|
+
// when the client closes the stream. No key custody, no global state; the
|
|
6
|
+
// per-call state lives entirely in the McpServer instance.
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { createMcpServer } from "../server.js";
|
|
9
|
+
export async function startStdioServer() {
|
|
10
|
+
const server = createMcpServer();
|
|
11
|
+
const transport = new StdioServerTransport();
|
|
12
|
+
await server.connect(transport);
|
|
13
|
+
// The transport owns the process from here; no shutdown signal handling -
|
|
14
|
+
// SIGPIPE / EOF on stdin terminates the loop naturally.
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crediolabs/policy-builder-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "MCP server exposing the OZ policy-synth core (record_transaction + synthesize_policy) over stdio and Streamable HTTP transports.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/src/index.js",
|
|
8
|
+
"types": "./dist/src/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/src/index.d.ts",
|
|
12
|
+
"bun": "./src/index.ts",
|
|
13
|
+
"import": "./dist/src/index.js",
|
|
14
|
+
"default": "./dist/src/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"bin": {
|
|
18
|
+
"policy-builder-mcp": "./dist/bin/server.js"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"src",
|
|
23
|
+
"!src/**/*.test.ts"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"build": "tsc -p tsconfig.build.json"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@crediolabs/policy-synth": "^0.1.0",
|
|
34
|
+
"@modelcontextprotocol/sdk": "1.18.1",
|
|
35
|
+
"zod": "3.25.76"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@biomejs/biome": "2.5.5",
|
|
39
|
+
"@types/node": "^26.1.1",
|
|
40
|
+
"typescript": "5.9.3"
|
|
41
|
+
}
|
|
42
|
+
}
|