mcdbus/tests/test_elicitation.py
Ryan Malloy c8a1ce0fdb Add desktop notification confirmation fallback, update README for PyPI
When MCP elicitation is unavailable (most clients), fall back to
org.freedesktop.Notifications with Approve/Deny action buttons.
Opt-in via MCDBUS_NOTIFY_CONFIRM=1. Silence (timeout) is denial.

Fixes signal race where NotificationClosed stomped ActionInvoked
result in the same event loop iteration.
2026-03-06 14:47:39 -07:00

166 lines
7.1 KiB
Python

"""Tests for elicitation (human-in-the-loop confirmation) on mutation tools."""
import os
from unittest.mock import AsyncMock, patch
import pytest
from fastmcp.exceptions import ToolError
from fastmcp.server.elicitation import (
AcceptedElicitation,
CancelledElicitation,
DeclinedElicitation,
)
from mcdbus._interaction import _confirm_or_abort
class TestConfirmOrAbort:
"""Unit tests for the _confirm_or_abort helper."""
async def test_accepted_proceeds(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=AcceptedElicitation(data="confirm"))
# Should return without raising
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_declined_raises_tool_error(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=DeclinedElicitation())
with pytest.raises(ToolError, match="cancelled by user"):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_accepted_wrong_key_raises(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=AcceptedElicitation(data="deny"))
with pytest.raises(ToolError, match="cancelled by user"):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_proceeds_by_default(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
# Client doesn't support elicitation — should proceed silently
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_hard_fails_when_required(self):
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
with patch.dict(os.environ, {"MCDBUS_REQUIRE_ELICITATION": "1"}):
with pytest.raises(ToolError, match="Elicitation required"):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_elicit_exception_treated_as_cancelled(self):
"""When ctx.elicit() throws (client lacks protocol support), proceed."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(side_effect=Exception("Method not found"))
# Should not raise — exception is treated as CancelledElicitation
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_elicit_exception_hard_fails_when_required(self):
"""When ctx.elicit() throws and MCDBUS_REQUIRE_ELICITATION is set, fail."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(side_effect=Exception("Method not found"))
with patch.dict(os.environ, {"MCDBUS_REQUIRE_ELICITATION": "1"}):
with pytest.raises(ToolError, match="Elicitation required"):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_notify_fallback_approved(self):
"""MCDBUS_NOTIFY_CONFIRM=1 + user approves → proceeds normally."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
mock_mgr = AsyncMock()
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
with (
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
patch("mcdbus._interaction.notify_confirm", return_value=True),
):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_notify_fallback_denied(self):
"""MCDBUS_NOTIFY_CONFIRM=1 + user denies → ToolError."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
mock_mgr = AsyncMock()
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
with (
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
patch("mcdbus._interaction.notify_confirm", return_value=False),
):
with pytest.raises(ToolError, match="denied via desktop notification"):
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_cancelled_notify_unavailable_proceeds(self):
"""Notification service failure → falls through with warning."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
mock_mgr = AsyncMock()
mock_mgr.get_bus = AsyncMock(return_value=AsyncMock())
with (
patch.dict(os.environ, {"MCDBUS_NOTIFY_CONFIRM": "1"}),
patch("mcdbus._interaction.get_mgr", return_value=mock_mgr),
patch(
"mcdbus._interaction.notify_confirm",
side_effect=RuntimeError("No notification service"),
),
):
# Should proceed without raising
await _confirm_or_abort(ctx, "Test message", "test_op")
async def test_require_elicitation_overrides_notify(self):
"""MCDBUS_REQUIRE_ELICITATION takes precedence over MCDBUS_NOTIFY_CONFIRM."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
with (
patch.dict(os.environ, {
"MCDBUS_REQUIRE_ELICITATION": "1",
"MCDBUS_NOTIFY_CONFIRM": "1",
}),
patch("mcdbus._interaction.notify_confirm") as mock_notify,
):
with pytest.raises(ToolError, match="Elicitation required"):
await _confirm_or_abort(ctx, "Test message", "test_op")
mock_notify.assert_not_called()
async def test_notify_not_attempted_without_env(self):
"""Without MCDBUS_NOTIFY_CONFIRM, notify_confirm is never called."""
ctx = AsyncMock()
ctx.elicit = AsyncMock(return_value=CancelledElicitation())
with patch("mcdbus._interaction.notify_confirm") as mock_notify:
await _confirm_or_abort(ctx, "Test message", "test_op")
mock_notify.assert_not_called()
class TestCallMethodElicitation:
"""Verify call_method triggers elicitation on system bus but not session bus."""
async def test_session_bus_no_elicitation(self, client):
"""Session bus calls should not trigger elicitation."""
# Ping on session bus — should succeed without any elicitation
result = await client.call_tool("call_method", {
"bus": "session",
"service": "org.freedesktop.DBus",
"object_path": "/org/freedesktop/DBus",
"interface": "org.freedesktop.DBus.Peer",
"method": "Ping",
})
text = result.content[0].text
assert "void" in text.lower()
class TestSetPropertyElicitation:
"""Verify set_property always triggers elicitation."""
async def test_invalid_property_still_validates_first(self, client):
"""Validation errors should fire before elicitation."""
with pytest.raises(ToolError, match="Invalid D-Bus"):
await client.call_tool("set_property", {
"bus": "session",
"service": "not-valid",
"object_path": "/foo",
"interface": "org.test.Iface",
"property_name": "Foo",
"value": '"bar"',
"signature": "s",
})