consequence-gate 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,316 @@
1
+ """
2
+ MCP Proxy: consequence-gate middleware for Model Context Protocol.
3
+
4
+ Intercepts tools/call requests, runs consequence simulation, and either:
5
+ - ALLOW: forwards request to downstream MCP server
6
+ - DENY: returns JSON-RPC error (code=-32603, "BLOCKED: <reason>")
7
+ - ASK: returns tool result with isError=true and "ESCALATION_REQUIRED" message
8
+ - STEER: returns tool result with isError=true and structured guidance
9
+
10
+ Transport: stdio (newline-delimited JSON-RPC)
11
+ - Reads from stdin (client -> proxy)
12
+ - Writes to stdout (proxy -> client)
13
+ - Forwards to downstream MCP server via subprocess stdio
14
+
15
+ MCP spec reference:
16
+ - tools/call: https://modelcontextprotocol.io/specification/2025-11-25/server/tools/
17
+ - Transport: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports/
18
+ - Error handling: https://apxml.com/courses/getting-started-model-context-protocol/chapter-3-implementing-tools-and-logic/error-handling-reporting
19
+ """
20
+
21
+ import json
22
+ import subprocess
23
+ import sys
24
+ from typing import Any, Callable, Dict, Optional
25
+
26
+ from ..core.models import GateDecision, EvaluationResult
27
+ from ..core.circuit_breaker import SteerCircuitBreaker
28
+ from ..simulators.financial import FinancialDeltaPredictor
29
+ from ..simulators.database import DataDeletionSimulator
30
+ from ..simulators.communications import OutboundCommunicationSimulator
31
+
32
+
33
+ class MCPConsequenceProxy:
34
+ """
35
+ MCP proxy that sits between an MCP client (Claude Desktop, Cursor, etc.)
36
+ and a downstream MCP server, intercepting tools/call requests to run
37
+ consequence simulation before forwarding.
38
+
39
+ Usage:
40
+ proxy = MCPConsequenceProxy(
41
+ downstream_command=["npx", "-y", "mcp-server-mytool"],
42
+ simulator_fn=financial_simulator,
43
+ evaluator_fn=evaluator,
44
+ circuit_breaker=SteerCircuitBreaker(),
45
+ )
46
+ proxy.run() # Blocks, reading from stdin, writing to stdout
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ downstream_command: list,
52
+ simulator_fn: Callable[[str, Dict[str, Any], Dict[str, Any]], Any],
53
+ evaluator_fn: Callable[[Any, SteerCircuitBreaker], EvaluationResult],
54
+ circuit_breaker: Optional[SteerCircuitBreaker] = None,
55
+ context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
56
+ ):
57
+ """
58
+ Args:
59
+ downstream_command: Command to launch downstream MCP server
60
+ simulator_fn: function(tool_name, args, context) -> delta
61
+ evaluator_fn: function(delta, circuit_breaker) -> EvaluationResult
62
+ circuit_breaker: SteerCircuitBreaker (default: max_retries=2)
63
+ context_provider: function(request_params) -> context dict
64
+ """
65
+ self.downstream_command = downstream_command
66
+ self.simulator_fn = simulator_fn
67
+ self.evaluator_fn = evaluator_fn
68
+ self.circuit_breaker = circuit_breaker or SteerCircuitBreaker(max_retries=2)
69
+ self.context_provider = context_provider or (lambda params: {})
70
+
71
+ self.downstream_process: Optional[subprocess.Popen] = None
72
+
73
+ def _extract_natural_key(self, tool_name: str, arguments: Dict[str, Any]) -> str:
74
+ """Extract stable natural key for idempotency."""
75
+ return arguments.get("claim_id") or arguments.get("transaction_ref") or f"{tool_name}:{json.dumps(arguments, sort_keys=True)}"
76
+
77
+ def _intercept_tools_call(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
78
+ """
79
+ Intercept a tools/call request. Returns a response dict if the gate
80
+ decides DENY/ASK/STEER, or None if the request should be forwarded.
81
+ """
82
+ params = request.get("params", {})
83
+ tool_name = params.get("name", "unknown")
84
+ arguments = params.get("arguments", {})
85
+ context = self.context_provider(params)
86
+
87
+ natural_key = self._extract_natural_key(tool_name, arguments)
88
+ delta = self.simulator_fn(tool_name, arguments, context)
89
+ result = self.evaluator_fn(delta, self.circuit_breaker)
90
+
91
+ request_id = request.get("id")
92
+
93
+ if result.decision == GateDecision.ALLOW:
94
+ return None # Forward to downstream
95
+
96
+ if result.decision == GateDecision.DENY:
97
+ # Protocol error - model cannot retry
98
+ return {
99
+ "jsonrpc": "2.0",
100
+ "id": request_id,
101
+ "error": {
102
+ "code": -32603,
103
+ "message": f"BLOCKED: {result.reason}",
104
+ },
105
+ }
106
+
107
+ if result.decision in (GateDecision.ASK, GateDecision.STEER):
108
+ # Tool execution error - model can retry with adjusted parameters
109
+ if result.decision == GateDecision.ASK:
110
+ error_text = f"ESCALATION_REQUIRED: {result.reason}"
111
+ else: # STEER
112
+ steer_payload = result.steer_payload or {}
113
+ guidance = steer_payload.get("guidance", result.reason)
114
+ suggested_tool = steer_payload.get("suggested_tool")
115
+ suggested_args = steer_payload.get("suggested_args", {})
116
+ idempotency_key = suggested_args.get("idempotency_key")
117
+ if idempotency_key:
118
+ guidance += f" [idempotency_key={idempotency_key}]"
119
+ error_text = (
120
+ f"STEER_GUIDANCE: {guidance}\n"
121
+ f"Suggested alternative: {suggested_tool} with args {suggested_args}"
122
+ )
123
+
124
+ return {
125
+ "jsonrpc": "2.0",
126
+ "id": request_id,
127
+ "result": {
128
+ "content": [{"type": "text", "text": error_text}],
129
+ "isError": True,
130
+ },
131
+ }
132
+
133
+ return None # Should not reach here
134
+
135
+ def _forward_to_downstream(self, request: Dict[str, Any]) -> Dict[str, Any]:
136
+ """Forward request to downstream MCP server and return response."""
137
+ if self.downstream_process is None:
138
+ self.downstream_process = subprocess.Popen(
139
+ self.downstream_command,
140
+ stdin=subprocess.PIPE,
141
+ stdout=subprocess.PIPE,
142
+ stderr=subprocess.PIPE,
143
+ text=True,
144
+ bufsize=1,
145
+ )
146
+
147
+ # Write request to downstream stdin
148
+ request_line = json.dumps(request) + "\n"
149
+ self.downstream_process.stdin.write(request_line)
150
+ self.downstream_process.stdin.flush()
151
+
152
+ # Read response from downstream stdout
153
+ response_line = self.downstream_process.stdout.readline()
154
+ return json.loads(response_line)
155
+
156
+ def _process_line(self, line: str) -> Optional[str]:
157
+ """Process a single JSON-RPC line from client."""
158
+ try:
159
+ request = json.loads(line)
160
+ except json.JSONDecodeError:
161
+ # Malformed JSON - forward as-is, let downstream handle
162
+ return None
163
+
164
+ method = request.get("method")
165
+ if method != "tools/call":
166
+ # Not a tool call - forward as-is
167
+ return None
168
+
169
+ # Intercept tools/call
170
+ intercepted_response = self._intercept_tools_call(request)
171
+ if intercepted_response is not None:
172
+ # Gate decided - return response directly to client
173
+ return json.dumps(intercepted_response)
174
+
175
+ # Gate allowed - forward to downstream
176
+ response = self._forward_to_downstream(request)
177
+ return json.dumps(response)
178
+
179
+ def run(self):
180
+ """Main proxy loop: read from stdin, process, write to stdout."""
181
+ try:
182
+ for line in sys.stdin:
183
+ line = line.strip()
184
+ if not line:
185
+ continue
186
+
187
+ response_line = self._process_line(line)
188
+ if response_line is not None:
189
+ sys.stdout.write(response_line + "\n")
190
+ sys.stdout.flush()
191
+ except KeyboardInterrupt:
192
+ pass
193
+ finally:
194
+ if self.downstream_process is not None:
195
+ self.downstream_process.terminate()
196
+
197
+
198
+ # Convenience factory functions
199
+
200
+ def create_financial_mcp_proxy(
201
+ downstream_command: list,
202
+ daily_tier_limit_inr: float = 25000.0,
203
+ instant_wire_threshold: float = 10000.0,
204
+ max_retries: int = 2,
205
+ context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
206
+ ) -> MCPConsequenceProxy:
207
+ """
208
+ Factory for financial-disbursement MCP proxy.
209
+
210
+ Usage:
211
+ proxy = create_financial_mcp_proxy(
212
+ downstream_command=["npx", "-y", "mcp-server-payments"],
213
+ daily_tier_limit_inr=25000.0,
214
+ context_provider=lambda params: {
215
+ "account_rolling_24h_spend": get_spend(params),
216
+ "kyc_verified": is_kyc_verified(params),
217
+ },
218
+ )
219
+ proxy.run()
220
+ """
221
+ predictor = FinancialDeltaPredictor(
222
+ daily_tier_limit_inr=daily_tier_limit_inr,
223
+ instant_wire_threshold=instant_wire_threshold,
224
+ )
225
+ breaker = SteerCircuitBreaker(max_retries=max_retries)
226
+
227
+ def evaluator(delta, circuit_breaker):
228
+ return predictor.evaluate(delta, circuit_breaker)
229
+
230
+ return MCPConsequenceProxy(
231
+ downstream_command=downstream_command,
232
+ simulator_fn=predictor.simulate,
233
+ evaluator_fn=evaluator,
234
+ circuit_breaker=breaker,
235
+ context_provider=context_provider,
236
+ )
237
+
238
+
239
+ def create_database_mcp_proxy(
240
+ downstream_command: list,
241
+ max_autonomous_delete_rows: int = 100,
242
+ db_conn=None,
243
+ max_retries: int = 2,
244
+ context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
245
+ ) -> MCPConsequenceProxy:
246
+ """
247
+ Factory for database-deletion MCP proxy.
248
+
249
+ Usage:
250
+ proxy = create_database_mcp_proxy(
251
+ downstream_command=["npx", "-y", "mcp-server-postgres"],
252
+ max_autonomous_delete_rows=100,
253
+ db_conn=get_db_connection(),
254
+ )
255
+ proxy.run()
256
+ """
257
+ simulator = DataDeletionSimulator(
258
+ max_autonomous_delete_rows=max_autonomous_delete_rows,
259
+ db_conn=db_conn,
260
+ )
261
+ breaker = SteerCircuitBreaker(max_retries=max_retries)
262
+
263
+ def evaluator(delta, circuit_breaker):
264
+ return simulator.evaluate(delta, circuit_breaker)
265
+
266
+ return MCPConsequenceProxy(
267
+ downstream_command=downstream_command,
268
+ simulator_fn=simulator.simulate,
269
+ evaluator_fn=evaluator,
270
+ circuit_breaker=breaker,
271
+ context_provider=context_provider,
272
+ )
273
+
274
+
275
+ def create_communications_mcp_proxy(
276
+ downstream_command: list,
277
+ max_autonomous_recipients: int = 10000,
278
+ canary_min_size: int = 100,
279
+ canary_max_bounce_rate: float = 0.05,
280
+ canary_max_complaint_rate: float = 0.01,
281
+ max_retries: int = 2,
282
+ context_provider: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None,
283
+ ) -> MCPConsequenceProxy:
284
+ """
285
+ Factory for communications-blast MCP proxy.
286
+
287
+ Usage:
288
+ proxy = create_communications_mcp_proxy(
289
+ downstream_command=["npx", "-y", "mcp-server-sendgrid"],
290
+ max_autonomous_recipients=10000,
291
+ context_provider=lambda params: {
292
+ "segment_counts": get_segments(params),
293
+ "recent_unsubscribes": get_unsubscribes(params),
294
+ "historical_bounce_rate": 0.02,
295
+ },
296
+ )
297
+ proxy.run()
298
+ """
299
+ simulator = OutboundCommunicationSimulator(
300
+ max_autonomous_recipients=max_autonomous_recipients,
301
+ canary_min_size=canary_min_size,
302
+ canary_max_bounce_rate=canary_max_bounce_rate,
303
+ canary_max_complaint_rate=canary_max_complaint_rate,
304
+ )
305
+ breaker = SteerCircuitBreaker(max_retries=max_retries)
306
+
307
+ def evaluator(delta, circuit_breaker):
308
+ return simulator.evaluate(delta, circuit_breaker)
309
+
310
+ return MCPConsequenceProxy(
311
+ downstream_command=downstream_command,
312
+ simulator_fn=simulator.simulate,
313
+ evaluator_fn=evaluator,
314
+ circuit_breaker=breaker,
315
+ context_provider=context_provider,
316
+ )
@@ -0,0 +1,224 @@
1
+ """
2
+ Strands Agents integration: BeforeToolCallEvent hook adapter.
3
+
4
+ Strands API reference:
5
+ - BeforeToolCallEvent: https://strandsagents.com/docs/api/python/strands.hooks.events/
6
+ - Event attributes: selected_tool, cancel_tool (string or True)
7
+ - No event.set_result() exists — steering must be done via cancellation
8
+ message that the agent sees on its next turn and can re-reason from.
9
+
10
+ Design contract (from project history):
11
+ - No silent argument mutation — we cancel with guidance, not rewrite args.
12
+ - Idempotency keys derived from natural_key, not regenerated per retry.
13
+ - Hard retry cap (default: 2) before forced escalation to ASK.
14
+ - Confidence-gated escalation — low confidence routes to ASK, never
15
+ confident ALLOW/DENY on unfounded projections.
16
+ """
17
+
18
+ from typing import Any, Callable, Dict, Optional
19
+ import json
20
+
21
+ try:
22
+ from strands.hooks import BeforeToolCallEvent
23
+ from strands.hooks.events import HookProvider, HookRegistry
24
+ except ImportError:
25
+ class HookProvider: # type: ignore[no-redef]
26
+ """Fallback base class when strands-agents is not installed."""
27
+ pass
28
+
29
+ BeforeToolCallEvent = Any # type: ignore[misc,assignment]
30
+ HookRegistry = Any # type: ignore[misc,assignment]
31
+
32
+ from ..core.models import GateDecision, EvaluationResult
33
+ from ..core.circuit_breaker import SteerCircuitBreaker
34
+ from ..simulators.financial import FinancialDeltaPredictor
35
+ from ..simulators.database import DataDeletionSimulator
36
+
37
+
38
+ class ConsequenceGateHook(HookProvider):
39
+ """
40
+ Strands hook provider that intercepts BeforeToolCallEvent, runs the
41
+ consequence simulation gate, and either:
42
+ - ALLOW: returns cleanly, tool executes normally
43
+ - DENY: sets event.cancel_tool with a hard-block message
44
+ - ASK: sets event.cancel_tool with an escalation message
45
+ - STEER: sets event.cancel_tool with structured guidance that the
46
+ agent can parse and retry toward on its next turn
47
+
48
+ Usage:
49
+ from consequence_gate.integrations.strands_hook import ConsequenceGateHook
50
+
51
+ hook = ConsequenceGateHook(
52
+ simulator_fn=financial_simulator, # or database_simulator, etc.
53
+ circuit_breaker=SteerCircuitBreaker(max_retries=2),
54
+ )
55
+ agent = Agent(hooks=[hook])
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ simulator_fn: Callable[[str, Dict[str, Any], Dict[str, Any]], Any],
61
+ evaluator_fn: Optional[Callable[[Any, SteerCircuitBreaker], EvaluationResult]] = None,
62
+ circuit_breaker: Optional[SteerCircuitBreaker] = None,
63
+ context_provider: Optional[Callable[[BeforeToolCallEvent], Dict[str, Any]]] = None,
64
+ ):
65
+ """
66
+ Args:
67
+ simulator_fn: function(tool_name, args, context) -> delta object
68
+ evaluator_fn: function(delta, circuit_breaker) -> EvaluationResult
69
+ If None, uses a default generic evaluator.
70
+ circuit_breaker: SteerCircuitBreaker instance (default: max_retries=2)
71
+ context_provider: function(event) -> context dict for simulation
72
+ If None, uses a minimal default context.
73
+ """
74
+ self.simulator_fn = simulator_fn
75
+ self.evaluator_fn = evaluator_fn
76
+ self.circuit_breaker = circuit_breaker or SteerCircuitBreaker(max_retries=2)
77
+ self.context_provider = context_provider or self._default_context
78
+
79
+ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
80
+ registry.add_callback(BeforeToolCallEvent, self.intercept)
81
+
82
+ def _default_context(self, event: BeforeToolCallEvent) -> Dict[str, Any]:
83
+ """
84
+ Minimal default context extractor. Override this to pull in
85
+ session state, user identity, account telemetry, etc.
86
+ """
87
+ return {
88
+ "tool_use_id": event.tool_use.get("toolUseId"),
89
+ "conversation_id": getattr(event, "invocation_state", {}).get("conversation_id"),
90
+ }
91
+
92
+ def intercept(self, event: BeforeToolCallEvent) -> None:
93
+ tool_name = event.tool_use.get("name", "unknown")
94
+ args = event.tool_use.get("input", {})
95
+ context = self.context_provider(event)
96
+
97
+ # Extract natural key from args (domain-specific; financial uses claim_id,
98
+ # database uses table+filter hash, etc.)
99
+ natural_key = args.get("claim_id") or args.get("transaction_ref") or f"{tool_name}:{json.dumps(args, sort_keys=True)}"
100
+
101
+ # Run simulation + evaluation
102
+ delta = self.simulator_fn(tool_name, args, context)
103
+
104
+ if self.evaluator_fn is not None:
105
+ result = self.evaluator_fn(delta, self.circuit_breaker)
106
+ else:
107
+ # Fallback: generic evaluator (not domain-aware, but safe)
108
+ from ..core.evaluator import BlastRadiusEvaluator
109
+ evaluator = BlastRadiusEvaluator()
110
+ gate_decision = evaluator.evaluate(delta)
111
+ # Wrap generic decision into EvaluationResult for uniform handling
112
+ result = EvaluationResult(
113
+ decision=gate_decision,
114
+ confidence=delta.confidence,
115
+ reason="Generic evaluator fallback.",
116
+ )
117
+
118
+ # Apply decision
119
+ if result.decision == GateDecision.ALLOW:
120
+ # Pass through — do nothing, tool executes normally
121
+ return
122
+
123
+ if result.decision == GateDecision.DENY:
124
+ event.cancel_tool = f"BLOCKED: {result.reason}"
125
+ return
126
+
127
+ if result.decision == GateDecision.ASK:
128
+ event.cancel_tool = f"ESCALATION_REQUIRED: {result.reason}"
129
+ return
130
+
131
+ if result.decision == GateDecision.STEER:
132
+ # Cancel with structured guidance — agent sees this message
133
+ # on its next turn and can re-reason toward the safer path.
134
+ steer_payload = result.steer_payload or {}
135
+ guidance_text = steer_payload.get("guidance", result.reason)
136
+ suggested_tool = steer_payload.get("suggested_tool")
137
+ suggested_args = steer_payload.get("suggested_args", {})
138
+
139
+ # Include idempotency key in the guidance so the agent can
140
+ # include it when retrying (if the suggested tool expects it).
141
+ idempotency_key = suggested_args.get("idempotency_key")
142
+ if idempotency_key:
143
+ guidance_text += f" [idempotency_key={idempotency_key}]"
144
+
145
+ cancel_message = (
146
+ f"STEER_GUIDANCE: {guidance_text}\n"
147
+ f"Suggested alternative: {suggested_tool} with args {suggested_args}"
148
+ )
149
+ event.cancel_tool = cancel_message
150
+ return
151
+
152
+
153
+ # Convenience factory functions for common domain simulators
154
+
155
+ def create_financial_gate_hook(
156
+ daily_tier_limit_inr: float = 25000.0,
157
+ instant_wire_threshold: float = 10000.0,
158
+ max_retries: int = 2,
159
+ context_provider: Optional[Callable[[BeforeToolCallEvent], Dict[str, Any]]] = None,
160
+ ) -> ConsequenceGateHook:
161
+ """
162
+ Factory for a financial-disbursement gate hook.
163
+
164
+ Usage:
165
+ hook = create_financial_gate_hook(
166
+ daily_tier_limit_inr=25000.0,
167
+ context_provider=lambda event: {
168
+ "account_rolling_24h_spend": get_current_spend(event),
169
+ "kyc_verified": is_kyc_verified(event),
170
+ },
171
+ )
172
+ agent = Agent(hooks=[hook])
173
+ """
174
+ predictor = FinancialDeltaPredictor(
175
+ daily_tier_limit_inr=daily_tier_limit_inr,
176
+ instant_wire_threshold=instant_wire_threshold,
177
+ )
178
+ breaker = SteerCircuitBreaker(max_retries=max_retries)
179
+
180
+ def evaluator(delta, circuit_breaker):
181
+ return predictor.evaluate(delta, circuit_breaker)
182
+
183
+ return ConsequenceGateHook(
184
+ simulator_fn=predictor.simulate,
185
+ evaluator_fn=evaluator,
186
+ circuit_breaker=breaker,
187
+ context_provider=context_provider,
188
+ )
189
+
190
+
191
+ def create_database_gate_hook(
192
+ max_autonomous_delete_rows: int = 100,
193
+ db_conn=None,
194
+ max_retries: int = 2,
195
+ context_provider: Optional[Callable[[BeforeToolCallEvent], Dict[str, Any]]] = None,
196
+ ) -> ConsequenceGateHook:
197
+ """
198
+ Factory for a database-deletion gate hook.
199
+
200
+ Usage:
201
+ hook = create_database_gate_hook(
202
+ max_autonomous_delete_rows=100,
203
+ db_conn=get_db_connection(),
204
+ context_provider=lambda event: {
205
+ "table_metadata": get_table_metadata(event),
206
+ },
207
+ )
208
+ agent = Agent(hooks=[hook])
209
+ """
210
+ simulator = DataDeletionSimulator(
211
+ max_autonomous_delete_rows=max_autonomous_delete_rows,
212
+ db_conn=db_conn,
213
+ )
214
+ breaker = SteerCircuitBreaker(max_retries=max_retries)
215
+
216
+ def evaluator(delta, circuit_breaker):
217
+ return simulator.evaluate(delta, circuit_breaker)
218
+
219
+ return ConsequenceGateHook(
220
+ simulator_fn=simulator.simulate,
221
+ evaluator_fn=evaluator,
222
+ circuit_breaker=breaker,
223
+ context_provider=context_provider,
224
+ )
File without changes