Skip to content

Commit b3e8107

Browse files
committed
fix(policy): drop approve_url, instruct LLM to send user to settings page (#966)
The relative-path approve_url doesn't resolve cleanly through cloudflared or other reverse-proxy deployment modes — the LLM can't safely hand it to the user. The user already knows where the Tool Security Policies tab is (they set the rule from it), and that page lists all pending approvals, so a per-request URL is unnecessary noise. - Drop approve_url from USER_APPROVAL_REQUIRED context; keep `token` so a caller could correlate but the user doesn't need to act on it. - Update message + progress text to instruct the LLM to tell the user to open the settings UI Tool Security Policies tab. - Drop the now-unused approval_url_builder param + the _settings_secret_prefix plumbing in server.py / settings_ui.py. Also fix the failing test_defaults (asserted dropped Policy.enabled field) and the e2e test PUT body that still carried `"enabled": True`.
1 parent 2edb043 commit b3e8107

7 files changed

Lines changed: 14 additions & 50 deletions

File tree

src/ha_mcp/policy/middleware.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,10 @@ def __init__(
4444
*,
4545
policy_provider: Callable[[], Policy],
4646
queue: ApprovalQueue,
47-
approval_url_builder: Callable[[str], str] | None = None,
4847
wait_seconds: int | None = None,
4948
) -> None:
5049
self._policy_provider = policy_provider
5150
self._queue = queue
52-
self._approval_url_builder = approval_url_builder or (
53-
lambda token: f"/settings?tab=tool-security-policies&token={token}"
54-
)
5551
self._wait_override = wait_seconds
5652

5753
async def on_call_tool(
@@ -117,14 +113,13 @@ async def on_call_tool(
117113
args,
118114
ttl_minutes=policy.approval_ttl_minutes,
119115
)
120-
approval_url = self._approval_url_builder(pending.token)
121116

122117
wait = (
123118
self._wait_override
124119
if self._wait_override is not None
125120
else policy.wait_seconds
126121
)
127-
await self._wait_for_decision(context, pending, approval_url, wait)
122+
await self._wait_for_decision(context, pending, wait)
128123

129124
if pending.decision == "approved":
130125
self._queue.consume_and_maybe_remember(
@@ -136,13 +131,12 @@ async def on_call_tool(
136131
self._queue.remove(pending.token)
137132
raise self._denied_error()
138133

139-
raise self._pending_error(pending, approval_url)
134+
raise self._pending_error(pending)
140135

141136
async def _wait_for_decision(
142137
self,
143138
context: MiddlewareContext,
144139
pending: PendingApproval,
145-
approval_url: str,
146140
wait_seconds: int,
147141
) -> None:
148142
deadline = anyio.current_time() + wait_seconds
@@ -153,8 +147,9 @@ async def _wait_for_decision(
153147
progress=0,
154148
total=0,
155149
message=(
156-
f"Awaiting user approval — open this URL to review the call "
157-
f"in the Tool Security Policies tab and approve it: {approval_url}"
150+
"Awaiting user approval — open the ha-mcp settings UI, "
151+
"go to the Tool Security Policies tab, and approve or deny "
152+
"the pending request."
158153
),
159154
)
160155
remaining = deadline - anyio.current_time()
@@ -180,7 +175,7 @@ def _denied_error() -> ToolError:
180175
)
181176
)
182177

183-
def _pending_error(self, pending: PendingApproval, approval_url: str) -> ToolError:
178+
def _pending_error(self, pending: PendingApproval) -> ToolError:
184179
# Time-remaining, not total TTL: an LLM that re-calls a minute
185180
# before expiry should see "~60s left", not the original 300s.
186181
remaining = max(
@@ -193,16 +188,17 @@ def _pending_error(self, pending: PendingApproval, approval_url: str) -> ToolErr
193188
"error": {
194189
"code": "USER_APPROVAL_REQUIRED",
195190
"message": (
196-
f"User approval required. Open this URL to review the call "
197-
f"in the Tool Security Policies tab and approve it: {approval_url}. "
198-
"Re-call this tool with the same arguments after the user approves."
191+
"User approval required. Tell the user to open the "
192+
"ha-mcp settings UI, go to the Tool Security Policies "
193+
"tab, and approve or deny the pending request. Re-call "
194+
"this tool with the same arguments after the user approves."
199195
),
200196
"context": {
201-
"approve_url": approval_url,
197+
"token": pending.token,
202198
"expires_in_seconds": remaining,
203199
},
204200
"suggestions": [
205-
"Tell the user to click the approval link.",
201+
"Tell the user to open the Tool Security Policies tab in the ha-mcp settings UI and approve the pending request.",
206202
"Re-call this tool with the same arguments after the user approves.",
207203
],
208204
},

src/ha_mcp/server.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,6 @@ def __init__(
9595
# Used by _apply_tool_search to remove default-pinned tools from
9696
# the always_visible set so users can unpin defaults (#966).
9797
self._user_enabled_tools: set[str] = set()
98-
# Set by register_settings_routes (settings_ui.py) when the
99-
# settings UI routes are mounted under the secret-prefixed path.
100-
# Declared on __init__ so pyright doesn't flag the external
101-
# assignment as reportAttributeAccessIssue.
102-
self._settings_secret_prefix: str = ""
10398

10499
# Get server name/version from settings if no client provided
105100
if not self._client_provided:
@@ -892,23 +887,11 @@ def _policy_provider() -> Policy:
892887
# roundtrip of a gated tool call.
893888
return load_policy(data_dir)
894889

895-
# The secret prefix (e.g. ``/private_xxx``) is wired in lazily
896-
# by ``settings_ui.register_settings_routes`` after the HTTP
897-
# entrypoint resolves ``MCP_SECRET_PATH``. Read at URL-build
898-
# time so the closure picks up the value once it's set.
899-
# Empty-string fallback (add-on mode + root-mount + stdio) keeps
900-
# the existing relative-URL behavior, which already resolves
901-
# correctly against the settings page the user navigates to.
902-
def _approval_url(token: str) -> str:
903-
prefix = getattr(self, "_settings_secret_prefix", "") or ""
904-
return f"{prefix}/settings?tab=tool-security-policies&token={token}"
905-
906890
try:
907891
self.mcp.add_middleware(
908892
PolicyMiddleware(
909893
policy_provider=_policy_provider,
910894
queue=self.approval_queue,
911-
approval_url_builder=_approval_url,
912895
)
913896
)
914897
logger.info(

src/ha_mcp/settings_ui.py

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3939,17 +3939,6 @@ def register_settings_routes(
39393939
secret_prefix = secret_path.rstrip("/") if secret_path else ""
39403940
is_addon = is_running_in_addon()
39413941

3942-
# Expose the resolved prefix to the server so the tool security policies
3943-
# middleware can build absolute-looking approval URLs (#966). The
3944-
# middleware reads this lazily via ``getattr(self,
3945-
# "_settings_secret_prefix", "")`` so the closure picks up the value
3946-
# set here, even though ``_apply_tool_security_policies`` ran in __init__
3947-
# before this function was called. Skip when ``server is None`` (sidecar
3948-
# / unit-test shape) — the policy middleware isn't registered in that
3949-
# mode anyway, and a Mock-less ``None`` would raise AttributeError here.
3950-
if server is not None:
3951-
server._settings_secret_prefix = secret_prefix
3952-
39533942
if not is_addon and not secret_prefix:
39543943
logger.warning(
39553944
"register_settings_routes: not in add-on mode and no secret_path "

tests/src/e2e/policy/test_approval_flow.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ async def test_blocked_call_then_approve_then_recall(policy_enabled_mcp):
144144
current_resp = await handlers["policy_get_config"](_make_request())
145145
current = json.loads(current_resp.body)
146146
new_policy = {
147-
"enabled": True,
148147
"wait_seconds": 5,
149148
"approval_ttl_minutes": 5,
150149
"rules": [

tests/src/unit/policy/test_middleware.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ async def test_timeout_raises_pending_error_and_keeps_entry(queue):
155155
)
156156
body = json.loads(ei.value.args[0])
157157
assert body["error"]["code"] == "USER_APPROVAL_REQUIRED"
158-
assert "approve_url" in body["error"]["context"]
158+
assert body["error"]["context"]["token"]
159+
assert "Tool Security Policies" in body["error"]["message"]
159160
call_next.assert_not_called()
160161
# entry survives for re-call
161162
assert queue.list_pending()

tests/src/unit/policy/test_model.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,15 +86,13 @@ def test_remember_minutes_non_negative(self):
8686
class TestPolicy:
8787
def test_defaults(self):
8888
p = Policy()
89-
assert p.enabled is False
9089
assert p.wait_seconds == 60
9190
assert p.approval_ttl_minutes == 5
9291
assert p.rules == []
9392

9493
def test_user_example_from_issue(self):
9594
"""Example from the maintainer: ha_call_service approval when domain is lock or alarm_control_panel."""
9695
p = Policy(
97-
enabled=True,
9896
rules=[
9997
Rule(
10098
tool_name="ha_call_service",

tests/src/unit/test_server_policy_wiring.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,10 @@ def _make_server_stub(*, enable_policies: bool) -> MagicMock:
2020
* ``self.settings.enable_tool_security_policies`` (early return gate)
2121
* ``self.approval_queue = ApprovalQueue()`` (attribute write)
2222
* ``self.mcp.add_middleware(...)`` (the wiring side-effect)
23-
* ``self._settings_secret_prefix`` (read by the URL-builder closure)
2423
"""
2524
stub = MagicMock()
2625
stub.settings = MagicMock(enable_tool_security_policies=enable_policies)
2726
stub.mcp = MagicMock()
28-
stub._settings_secret_prefix = ""
2927
# Explicitly start without the attribute the method is supposed to
3028
# set, so the disabled-case assertion is a real signal.
3129
del stub.approval_queue

0 commit comments

Comments
 (0)