Skip to content

Commit 21fe39e

Browse files
committed
Gate destructive tools behind elicitation confirmation
Add MCPServer.request_elicitation that fires a server-initiated elicitation/create when JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE=1 and the client advertised the elicitation capability. Tools whose annotations mark them destructive (and not read-only) are gated: the user sees a confirmation prompt before the action runs, and declining returns a clean -32000 error to the model. Servers that talk to non-elicitation clients fall through with a logged warning so the feature is opt-in and never blocks unexpectedly.
1 parent 7c87e37 commit 21fe39e

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

je_auto_control/utils/mcp_server/server.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ def _handle_tools_call(self, msg_id: Any,
510510
raise _MCPError(-32602, f"Invalid arguments for {name}: {violation}")
511511
if self._rate_limiter is not None and not self._rate_limiter.try_acquire():
512512
raise _MCPError(-32000, f"Rate limit exceeded for tool {name!r}")
513+
self._maybe_confirm_destructive(name, tool, arguments)
513514
ctx = self._build_call_context(msg_id, params)
514515
with self._calls_lock:
515516
self._active_calls[msg_id] = ctx
@@ -551,6 +552,21 @@ def _handle_tools_call(self, msg_id: Any,
551552
"isError": False,
552553
}
553554

555+
def request_elicitation(self, message: str,
556+
requested_schema: Optional[Dict[str, Any]] = None,
557+
timeout: float = 60.0) -> Dict[str, Any]:
558+
"""Ask the connected client to elicit a response from the user.
559+
560+
Returns the raw payload (typically ``{"action": "accept" | "decline" | "cancel", ...}``).
561+
Requires the client to advertise the ``elicitation`` capability.
562+
"""
563+
params: Dict[str, Any] = {"message": str(message)}
564+
if requested_schema is not None:
565+
params["requestedSchema"] = requested_schema
566+
return self._send_outbound_request(
567+
"elicitation/create", params=params, timeout=timeout,
568+
)
569+
554570
def request_sampling(self, messages: List[Dict[str, Any]],
555571
system_prompt: Optional[str] = None,
556572
max_tokens: int = 1024,
@@ -600,6 +616,42 @@ def request_sampling(self, messages: List[Dict[str, Any]],
600616
raise RuntimeError(f"sampling failed: {slot['error']}")
601617
return slot.get("result") or {}
602618

619+
def _maybe_confirm_destructive(self, name: str, tool: MCPTool,
620+
arguments: Dict[str, Any]) -> None:
621+
"""Ask the client to confirm before running a destructive tool."""
622+
if not _confirm_destructive_enabled():
623+
return
624+
annotations = tool.annotations
625+
if annotations.read_only or not annotations.destructive:
626+
return
627+
if "elicitation" not in self._client_capabilities:
628+
autocontrol_logger.info(
629+
"MCP confirmation requested for %s but client lacks "
630+
"elicitation capability — proceeding without prompt", name,
631+
)
632+
return
633+
if self._writer is None:
634+
return
635+
prompt = (f"AutoControl is about to run a destructive tool "
636+
f"'{name}'. Continue?")
637+
try:
638+
response = self.request_elicitation(
639+
message=prompt, requested_schema={"type": "object",
640+
"properties": {}},
641+
timeout=60.0,
642+
)
643+
except (RuntimeError, TimeoutError) as error:
644+
autocontrol_logger.info(
645+
"MCP elicitation for %s failed (%r) — refusing call",
646+
name, error,
647+
)
648+
raise _MCPError(-32000,
649+
f"User confirmation unavailable for {name}")
650+
action = response.get("action") if isinstance(response, dict) else None
651+
if action != "accept":
652+
raise _MCPError(-32000, f"User declined to run {name}: action={action!r}")
653+
del arguments # available for future per-arg confirmation policies
654+
603655
def _build_call_context(self, msg_id: Any,
604656
params: Dict[str, Any]) -> ToolCallContext:
605657
meta = params.get("_meta") if isinstance(params.get("_meta"),
@@ -631,6 +683,12 @@ def _stringify_result(value: Any) -> str:
631683
return repr(value)
632684

633685

686+
def _confirm_destructive_enabled() -> bool:
687+
"""Return True when the operator wants destructive tools gated on user OK."""
688+
raw = os.environ.get("JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE", "")
689+
return raw.strip().lower() in {"1", "true", "yes", "on"}
690+
691+
634692
def _capture_error_screenshot(tool_name: str) -> Optional[str]:
635693
"""Save a debug screenshot when JE_AUTOCONTROL_MCP_ERROR_SHOTS is set."""
636694
debug_dir = os.environ.get("JE_AUTOCONTROL_MCP_ERROR_SHOTS")

test/unit_test/headless/test_mcp_server.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1575,6 +1575,116 @@ def test_resources_subscribe_rejects_unknown_uri():
15751575
assert response["error"]["code"] == -32602
15761576

15771577

1578+
def test_destructive_confirmation_blocks_when_user_declines(monkeypatch):
1579+
monkeypatch.setenv("JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE", "1")
1580+
captured_lines = []
1581+
tool = MCPTool(
1582+
name="zap", description="zap",
1583+
input_schema={"type": "object", "properties": {}},
1584+
handler=lambda: "should not run",
1585+
)
1586+
server = MCPServer(tools=[tool], concurrent_tools=True)
1587+
server.set_writer(captured_lines.append)
1588+
server._client_capabilities = {"elicitation": {}}
1589+
1590+
def run_call():
1591+
server.handle_line(_request("tools/call", msg_id=11, params={
1592+
"name": "zap", "arguments": {},
1593+
}))
1594+
1595+
t = threading.Thread(target=run_call)
1596+
t.start()
1597+
deadline = threading.Event()
1598+
for _ in range(200):
1599+
if any('"elicitation/create"' in line for line in captured_lines):
1600+
break
1601+
deadline.wait(0.01)
1602+
eli_lines = [line for line in captured_lines
1603+
if '"elicitation/create"' in line]
1604+
assert eli_lines, "expected elicitation/create"
1605+
eli_id = json.loads(eli_lines[-1])["id"]
1606+
1607+
server.handle_line(json.dumps({
1608+
"jsonrpc": "2.0", "id": eli_id,
1609+
"result": {"action": "decline"},
1610+
}))
1611+
t.join(timeout=2.0)
1612+
assert not t.is_alive()
1613+
final_lines = [line for line in captured_lines if '"id": 11' in line]
1614+
assert final_lines
1615+
final = json.loads(final_lines[-1])
1616+
assert final["error"]["code"] == -32000
1617+
assert "declined" in final["error"]["message"]
1618+
1619+
1620+
def test_destructive_confirmation_allows_when_user_accepts(monkeypatch):
1621+
monkeypatch.setenv("JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE", "1")
1622+
captured_lines = []
1623+
tool = MCPTool(
1624+
name="zap2", description="zap2",
1625+
input_schema={"type": "object", "properties": {}},
1626+
handler=lambda: "ran",
1627+
)
1628+
server = MCPServer(tools=[tool], concurrent_tools=True)
1629+
server.set_writer(captured_lines.append)
1630+
server._client_capabilities = {"elicitation": {}}
1631+
1632+
def run_call():
1633+
server.handle_line(_request("tools/call", msg_id=12, params={
1634+
"name": "zap2", "arguments": {},
1635+
}))
1636+
1637+
t = threading.Thread(target=run_call)
1638+
t.start()
1639+
deadline = threading.Event()
1640+
for _ in range(200):
1641+
if any('"elicitation/create"' in line for line in captured_lines):
1642+
break
1643+
deadline.wait(0.01)
1644+
eli_id = json.loads([line for line in captured_lines
1645+
if '"elicitation/create"' in line][-1])["id"]
1646+
1647+
server.handle_line(json.dumps({
1648+
"jsonrpc": "2.0", "id": eli_id,
1649+
"result": {"action": "accept", "content": {}},
1650+
}))
1651+
t.join(timeout=2.0)
1652+
final = json.loads([line for line in captured_lines
1653+
if '"id": 12' in line][-1])
1654+
assert final["result"]["isError"] is False
1655+
assert final["result"]["content"][0]["text"] == "ran"
1656+
1657+
1658+
def test_destructive_confirmation_skipped_when_client_lacks_capability(monkeypatch):
1659+
monkeypatch.setenv("JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE", "1")
1660+
tool = MCPTool(
1661+
name="zap3", description="zap3",
1662+
input_schema={"type": "object", "properties": {}},
1663+
handler=lambda: "ok",
1664+
)
1665+
server = MCPServer(tools=[tool])
1666+
# Client did not advertise elicitation — server proceeds without asking.
1667+
response = _decode(server.handle_line(_request("tools/call", params={
1668+
"name": "zap3", "arguments": {},
1669+
})))
1670+
assert response["result"]["isError"] is False
1671+
1672+
1673+
def test_destructive_confirmation_skipped_when_env_unset(monkeypatch):
1674+
monkeypatch.delenv("JE_AUTOCONTROL_MCP_CONFIRM_DESTRUCTIVE", raising=False)
1675+
tool = MCPTool(
1676+
name="zap4", description="zap4",
1677+
input_schema={"type": "object", "properties": {}},
1678+
handler=lambda: "ok",
1679+
)
1680+
server = MCPServer(tools=[tool])
1681+
server._client_capabilities = {"elicitation": {}}
1682+
response = _decode(server.handle_line(_request("tools/call", params={
1683+
"name": "zap4", "arguments": {},
1684+
})))
1685+
assert response["result"]["isError"] is False
1686+
1687+
15781688
def test_default_registry_lists_core_automation_tools():
15791689
names = {tool.name for tool in build_default_tool_registry()}
15801690
expected = {

0 commit comments

Comments
 (0)