@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/src/server.ts CHANGED
@@ -15,7 +15,6 @@ import type { ToolResponse } from '@crediolabs/policy-synth'
15
15
  import {
16
16
  runGetInterpreterInfo,
17
17
  runInstallPolicy,
18
- runMergePolicy,
19
18
  runRecordTransaction,
20
19
  runRevokePolicy,
21
20
  runSimulatePolicy,
@@ -27,7 +26,6 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
27
26
  import {
28
27
  GetInterpreterInfoToolShape,
29
28
  InstallPolicyToolShape,
30
- MergePolicyToolShape,
31
29
  RecordTransactionToolShape,
32
30
  RevokePolicyToolShape,
33
31
  SimulatePolicyToolShape,
@@ -66,23 +64,23 @@ export function registerTools(server: McpServer): void {
66
64
 
67
65
  server.tool(
68
66
  'synthesize_policy',
69
- 'Synthesize a ProposedPolicy from either a deterministic MandateSpec (`source: mandate`) or a RecordedTransaction (`source: recording`). The discriminated `source` field selects the front-end.',
67
+ 'Synthesize a ProposedPolicy from a RecordedTransaction (`source: recording`).',
70
68
  SynthesizePolicyToolShape,
71
69
  (args) => runSynthesizePolicy(args).then(toCallToolResult)
72
70
  )
73
71
 
74
72
  server.tool(
75
73
  'simulate_policy',
76
- 'Replay a RecordedTransaction against a proposed PredicateNode (or null for an OZ-only policy) and emit the SimulationResult envelope (permit verdict + deny-case battery). Returns a SIMULATION_ERROR ToolError on runtime evaluation failure.',
74
+ 'Evaluate a predicate against one recorded call and report permit/deny with the deny reason. The evaluator is a second implementation of the on-chain semantics, cross-checked against the Rust interpreter by the conformance harness, so a verdict here is a claim about what the contract would do. Pass the `predicate` returned by `synthesize_policy` under `explain`.',
77
75
  SimulatePolicyToolShape,
78
- (args) => runSimulatePolicy(args).then(toCallToolResult)
76
+ (args) => toCallToolResult(runSimulatePolicy(args))
79
77
  )
80
78
 
81
79
  server.tool(
82
80
  'verify_policy',
83
- 'Run the static minimality check on a proposed PredicateNode against a RecordedTransaction (no conjunct is load-bearing-free). Returns VERIFICATION_FAILED with the dropped-constraint fingerprints when the predicate is over-broad.',
81
+ 'Check a predicate against the transaction it was synthesised from, plus a generated deny case per dimension. Reports `ok` only when the permit case is permitted AND every deny case is denied - a denied permit case means the policy is too strict, a permitted deny case means it is too loose.',
84
82
  VerifyPolicyToolShape,
85
- (args) => runVerifyPolicy(args).then(toCallToolResult)
83
+ (args) => toCallToolResult(runVerifyPolicy(args))
86
84
  )
87
85
 
88
86
  server.tool(
@@ -99,13 +97,6 @@ export function registerTools(server: McpServer): void {
99
97
  (args) => runRevokePolicy(args).then(toCallToolResult)
100
98
  )
101
99
 
102
- server.tool(
103
- 'merge_policy',
104
- "Tighten a rule by replacing its predicate with the conjunction of the installed one and a new one. This is the remedy for the overlap `install_policy` reports between two rules the interpreter polices: because OpenZeppelin enforces only the rule a signer names, adding a second, tighter rule restricts nothing, so the restriction has to become one predicate. TWO transactions, in order: call with step 'detach' to remove the current attachment, wait for it to confirm, then call with step 'reinstall'. Detaching uninstalls the policy, which RESETS every counter on the rule, and the rule is unpoliced in between - both are reported in `warnings`.",
105
- MergePolicyToolShape,
106
- (args) => runMergePolicy(args).then(toCallToolResult)
107
- )
108
-
109
100
  server.tool(
110
101
  'get_interpreter_info',
111
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.',
@@ -2,13 +2,10 @@
2
2
  //
3
3
  // Streamable HTTP transport (hosted). Uses the Node http module directly so
4
4
  // the package stays thin - no express / hono dep. We run in STATELESS mode
5
- // (no `sessionIdGenerator` option), and the SDK 1.26+ requires a FRESH
6
- // transport per request in stateless mode: reusing one across requests lets
7
- // concurrent clients share internal message-id state, which is exactly the
8
- // cross-client data leak GHSA-345p-7cg4-v4c7 warns about. We therefore build
9
- // a transport inside the request handler, connect it, dispatch the request,
10
- // then close it (close() resets the McpServer's transport slot so the next
11
- // request can reconnect cleanly).
5
+ // (no sessionIdGenerator: the SDK disables session management when it is not
6
+ // provided) so each POST /mcp is its own transaction: this matches the
7
+ // brief's "stateless across calls" invariant. Stateless means a fresh server
8
+ // and transport per request - see the handler for why the SDK requires it.
12
9
  //
13
10
  // Single endpoint: POST /mcp (the SDK also accepts GET for SSE streaming, but
14
11
  // the T1 surface does not emit server-initiated messages so we omit it).
@@ -66,12 +63,6 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
66
63
  `startHttpServer: refusing to bind host ${host}: the MCP surface is unauthenticated, so only loopback (127.0.0.1, ::1, localhost) is permitted by default. Pass \`allowExternalHost: true\` to opt in to a non-loopback bind.`
67
64
  )
68
65
  }
69
- // The McpServer is stateless and per-process; we keep ONE instance alive
70
- // for the lifetime of the listener, but each request creates + connects a
71
- // fresh StreamableHTTPServerTransport (SDK 1.26+ refuses to reuse a
72
- // stateless transport across requests).
73
- const server = createMcpServer()
74
-
75
66
  const httpServer: Server = createServer(async (req, res) => {
76
67
  if (!req.url) {
77
68
  sendJson(res, 400, { error: 'missing url' })
@@ -118,22 +109,33 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
118
109
  return
119
110
  }
120
111
 
121
- // One transport per request. Omitting `sessionIdGenerator` selects
122
- // stateless mode in SDK 1.26+ (the previous `sessionIdGenerator:
123
- // undefined` form is rejected because the option type no longer
124
- // includes `undefined` under `exactOptionalPropertyTypes`).
125
- //
126
- // The cast `as unknown as Transport` is narrowly scoped to the SDK
127
- // boundary: the Node wrapper exposes `onclose` via getter/setter with
128
- // type `(() => void) | undefined`, while the `Transport` interface
129
- // declares `onclose?: () => void`. Under `exactOptionalPropertyTypes:
130
- // true` those are not structurally assignable. The runtime contract is
131
- // the same (the setter accepts `() => void`), so we cast once here.
112
+ // A stateless transport handles exactly ONE request: the SDK refuses to
113
+ // reuse one ("Stateless transport cannot be reused across requests"),
114
+ // because a shared instance would let concurrent clients collide on
115
+ // JSON-RPC message ids. So the server and its transport are built here,
116
+ // per request, and torn down when the response closes.
117
+ // No `sessionIdGenerator`: the SDK disables session management when the
118
+ // option is absent, which is the stateless mode this surface wants.
119
+ // Passing an explicit `undefined` means the same thing to the SDK but is
120
+ // not assignable under `exactOptionalPropertyTypes`, so omission is both
121
+ // the type-correct and the documented spelling.
122
+ const server = createMcpServer()
132
123
  const transport = new StreamableHTTPServerTransport()
133
- const sdkTransport = transport as unknown as Transport
134
- await server.connect(sdkTransport)
135
-
124
+ // Registered before dispatch, not after: once the response has closed the
125
+ // event is gone, so a listener attached afterwards would never fire and
126
+ // the pair would leak.
127
+ res.on('close', () => {
128
+ void transport.close().catch(() => {})
129
+ void server.close().catch(() => {})
130
+ })
136
131
  try {
132
+ // The SDK declares `Transport.onclose` as an optional `() => void`, but
133
+ // exposes it on this class as an accessor pair typed `(() => void) |
134
+ // undefined`. Those are not assignable under `exactOptionalPropertyTypes`.
135
+ // The widening is upstream and structural only - the runtime object does
136
+ // satisfy `Transport` - so the assertion is narrowed to this one call
137
+ // rather than relaxing the compiler flag for the whole package.
138
+ await server.connect(transport as unknown as Transport)
137
139
  // `handleRequest` writes the response and returns once the message has
138
140
  // been dispatched. No shared state across calls in stateless mode.
139
141
  await transport.handleRequest(req as IncomingMessage & { auth?: never }, res, body)
@@ -151,13 +153,6 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
151
153
  }
152
154
  sendJson(res, 500, { error })
153
155
  }
154
- } finally {
155
- // Closing the transport fires its `onclose`, which resets
156
- // `server._transport` to undefined so the next request can connect
157
- // a fresh transport. We swallow the close error: a request that
158
- // already wrote its response does not care whether the close path
159
- // threw.
160
- await transport.close().catch(() => {})
161
156
  }
162
157
  })
163
158
 
@@ -173,9 +168,11 @@ export async function startHttpServer(opts: StartHttpServerOptions): Promise<Run
173
168
  port: opts.port,
174
169
  host,
175
170
  path,
171
+ // Nothing outlives a request, so closing the listener is the whole
172
+ // shutdown: each request's server and transport are already torn down by
173
+ // the `close` handler on its own response.
176
174
  close: async () => {
177
175
  await new Promise<void>((resolve) => httpServer.close(() => resolve()))
178
- await server.close().catch(() => {})
179
176
  },
180
177
  }
181
178
  }