Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions mureo/mcp/plugin_semantics.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ class ToolSemantics:
"""Safety classification derived from a plugin tool's MCP metadata."""

mutating: bool
#: The tool's OWN ``annotations.readOnlyHint``, ``None`` when it declared
#: none. ``mutating`` is the DERIVED answer; this is the raw declaration,
#: so a consumer can tell "declared a mutation" from "guessed a mutation"
#: — ``None`` means undeclared, i.e. the name was the only signal.
read_only_hint: bool | None = None
reversal: dict[str, Any] | None = None
throttle: ThrottleConfig | None = None
observation_days: int | None = None
Expand Down Expand Up @@ -253,19 +258,28 @@ def _meta_mureo(tool: Tool) -> dict[str, Any]:
return {}


def _declared_read_only_hint(tool: Tool) -> bool | None:
"""The tool's own ``readOnlyHint``, or ``None`` when it declares none."""
hint = getattr(getattr(tool, "annotations", None), "readOnlyHint", None)
return None if hint is None else hint is True


def _is_read(tool: Tool) -> bool:
"""Is ``tool`` a read? Declaration first, name shape only as a fallback.

An explicit ``readOnlyHint`` always wins — including an explicit
``False``, which is a plugin author saying "this mutates" and must not
be overturned by a read-shaped name. Only when the hint is ABSENT does
the name decide, through the same vocabulary the rollback planner and
the guardrail pattern-fallback registration already share, so the three
surfaces cannot answer "is this a read?" differently (#517).
the name decide, through the STRICT matcher this surface shares with the
guardrail pattern-fallback registration, so the two cannot answer "is
this a read?" differently (#517). The rollback planner reads the same
vocabulary through a looser matcher of its own — see
:func:`mureo.core.tool_names.reads_as_a_report_only_action` for why that
one is deliberately separate rather than shared.
"""
hint = getattr(getattr(tool, "annotations", None), "readOnlyHint", None)
hint = _declared_read_only_hint(tool)
if hint is not None:
return hint is True
return hint
return is_read_only_tool_name(getattr(tool, "name", "") or "")


Expand Down Expand Up @@ -301,6 +315,7 @@ def derive_semantics(tool: Tool) -> ToolSemantics:

return ToolSemantics(
mutating=mutating,
read_only_hint=_declared_read_only_hint(tool),
reversal=reversal,
throttle=throttle,
observation_days=observation_days,
Expand Down
89 changes: 67 additions & 22 deletions mureo/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,38 @@ def _register_plugin_bid_declarations(
)


def _register_plugin_read_only_hints(
semantics: dict[str, ToolSemantics],
) -> None:
"""Publish plugin ``readOnlyHint`` declarations to the pure policy layer.

The learning-period pre-flight (:mod:`mureo.policy.learning_reset`) has to
answer "is this call a mutation?" for a plugin/bridged tool too — a plugin
or bridge can register its own learning rules under a ``tool_prefix``, so
those names really do reach it. Without the declaration it had only the
NAME to go on and was wrong in both directions: a read-shaped name that
declares ``readOnlyHint=False`` got no learning-period notice and no
``block_learning_resets`` refusal, and a mutation-shaped name that
declares ``readOnlyHint=True`` risked a spurious one. Only tools that
actually declared a hint are registered — absence must stay "undeclared",
never "read". Best-effort: a registry failure must not take the server
down.
"""
from mureo.policy.declarations import register_read_only_hint

for name, sem in semantics.items():
if sem.read_only_hint is None:
continue
try:
register_read_only_hint(name, sem.read_only_hint)
except Exception: # noqa: BLE001 — never break startup on a hint
logger.warning(
"could not register the readOnlyHint for plugin tool '%s'",
name,
exc_info=True,
)


def _register_plugin_pattern_fallbacks(
semantics: dict[str, ToolSemantics],
) -> None:
Expand All @@ -419,13 +451,27 @@ def _register_plugin_pattern_fallbacks(
(:mod:`mureo.policy.pattern_scan`) for the channels no declaration covers.

Reads are deliberately excluded — they move no money, so scanning their
arguments could only produce false denials — and "read" is decided by TWO
signals, because on this surface neither is sufficient alone:

- ``annotations.readOnlyHint``, when the tool declares it; and
arguments could only produce false denials — and "read" is decided by
DECLARATION first, NAME second: the same precedence
:func:`~mureo.mcp.plugin_semantics.derive_semantics` itself uses, so the
two surfaces cannot answer "is this a read?" differently.

- ``annotations.readOnlyHint``, when the tool declares it. An explicit
``False`` is a plugin author saying "this moves money"; overturning it
with a name guess silently dropped that tool's budget/bid cap, which is
the one failure this ordering exists to prevent. A declaration is
evidence, a name shape is a guess.
- the tool NAME, via the shared read vocabulary in
:mod:`mureo.core.tool_names`, single-sourced so the surfaces that use
it cannot drift.
:mod:`mureo.core.tool_names`, single-sourced so the surfaces that use it
cannot drift — consulted ONLY for a tool that declared nothing, which is
the case the fallback was introduced for: a manifest snapshot carries no
annotations at all, so a bridged read whose arguments carry a numeric
budget-shaped FILTER would otherwise be refused outright. The error
costs are asymmetric there: platform mutations are consistently
verb-named (``create_`` / ``update_`` / ``delete_`` / ``set_``), so a
read-shaped name is almost never a mutation, whereas a mutation-shaped
name that is really a read costs only a wasted scan of arguments that
carry no budget.

The matcher here is the STRICT one, :func:`~mureo.core.tool_names.
is_read_only_tool_name`, and that is deliberate. The rollback planner uses
Expand All @@ -435,29 +481,27 @@ def _register_plugin_pattern_fallbacks(
mutation admitted here silently loses its ``## Guardrails`` cap. Do not
"unify" the two — see that sibling's docstring for the argument.

The name check still matters after #517, which taught ``derive_semantics``
the same vocabulary for tools that declare NO ``readOnlyHint``: a plugin is
free to declare ``readOnlyHint=False`` on a read-shaped name, and that
declaration is believed for auditing (over-recording is harmless) but must
not turn a listing call carrying a numeric budget-shaped FILTER argument
into an outright refusal. The error costs are asymmetric: platform
mutations are consistently verb-named (``create_`` / ``update_`` /
``delete_`` / ``set_``), so a read-shaped name is almost never a mutation,
whereas a mutation-shaped name that is really a read costs only a wasted
scan of arguments that carry no budget.

Annotation coverage on a real bridged surface is now known rather than
assumed (#517): of 85 tools on one Amazon manifest, 83 declare
``readOnlyHint`` and 2 omit it — good enough to lead with the declaration,
not good enough to drop the name fallback.
For semantics produced by ``derive_semantics`` the undeclared read-shaped
case already arrives as ``mutating=False``, so the name check below is a
belt-and-braces guard for any semantics map NOT built by that function
rather than a load-bearing step on the normal path.

Annotation coverage on a real bridged surface is known rather than assumed
(#517): of 85 tools on one Amazon manifest, 83 declare ``readOnlyHint``
and 2 omit it — good enough to lead with the declaration, not good enough
to drop the name fallback.

Best-effort: a registry failure must not take the server down.
"""
from mureo.core.tool_names import is_read_only_tool_name
from mureo.policy.pattern_scan import register_pattern_fallback_tool

for name, sem in semantics.items():
if not sem.mutating or is_read_only_tool_name(name):
if not sem.mutating:
continue
# The name is a fallback, not an override: it decides only for a tool
# that declared no readOnlyHint at all.
if sem.read_only_hint is None and is_read_only_tool_name(name):
continue
try:
register_pattern_fallback_tool(name)
Expand Down Expand Up @@ -557,6 +601,7 @@ def _register_bridged_money_declarations(
_register_plugin_budget_declarations(_PLUGIN_SEMANTICS)
_register_plugin_bid_declarations(_PLUGIN_SEMANTICS)
_register_bridged_money_declarations(_PLUGIN_SEMANTICS, _PLUGIN_DISPATCH)
_register_plugin_read_only_hints(_PLUGIN_SEMANTICS)
_register_plugin_pattern_fallbacks(_PLUGIN_SEMANTICS)


Expand Down
37 changes: 37 additions & 0 deletions mureo/policy/declarations.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
- Their process-wide registries and register / lookup / reset helpers,
populated by ``mureo.mcp.server`` from plugin tool metadata at import so the
pure decision layer stays I/O-free and needs no plugin imports.
- The same for one tool's ``annotations.readOnlyHint``
(:func:`register_read_only_hint` / :func:`declared_read_only_hint` /
:func:`reset_read_only_hints`) — so a pure decision that has to ask "is this
a read?" can prefer the tool's own DECLARATION over the shape of its name.
- :func:`_declared_amount` and its numeric helpers (:func:`_saturate`, the
:data:`_UNREADABLE` sentinel) — the single reader that turns one declared
argument key or path into currency units, distinguishing "absent" from
Expand Down Expand Up @@ -330,6 +334,39 @@ def reset_bid_declarations() -> None:
_BID_DECLARATIONS.clear()


# Tool name → the tool's OWN ``annotations.readOnlyHint``. Populated by the
# MCP server from plugin tool metadata at import, exactly like the two money
# registries above, so the pure decision layer stays I/O-free. It holds only
# what a tool DECLARED: absence means "undeclared", never "read".
_READ_ONLY_HINTS: dict[str, bool] = {}


def register_read_only_hint(tool_name: str, read_only: bool) -> None:
"""Bind ``tool_name``'s declared ``readOnlyHint`` (last registration wins).

Only ever called for a tool that actually declared one. A pure decision
layer otherwise has nothing but the tool's NAME to go on, and a name shape
is a guess where a declaration is evidence — registering the declaration
lets the guess be demoted to a fallback.
"""
_READ_ONLY_HINTS[tool_name] = read_only


def declared_read_only_hint(tool_name: str) -> bool | None:
"""``tool_name``'s DECLARED ``readOnlyHint``, or ``None`` when undeclared.

``None`` is "the tool said nothing" and must not be read as "read": the
caller falls back to the name vocabulary for that case, which is the only
signal left.
"""
return _READ_ONLY_HINTS.get(tool_name)


def reset_read_only_hints() -> None:
"""Drop every hint registration (tests; a re-discovery re-registers)."""
_READ_ONLY_HINTS.clear()


class _Unreadable:
"""Sentinel: a declared budget key is PRESENT but not a usable number.

Expand Down
27 changes: 27 additions & 0 deletions mureo/policy/learning_reset.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@

from mureo.core.strategy_reminder import is_mutating_builtin_tool
from mureo.core.tool_names import is_read_only_tool_name
from mureo.policy.declarations import declared_read_only_hint
from mureo.policy.learning_rules import (
Evidence,
LearningState,
Expand Down Expand Up @@ -162,11 +163,37 @@ def _is_mutation(tool_name: str) -> bool:
cannot restart a learning period on any platform. Getting this right is
what keeps the check quiet — a check that fires on ``campaigns_list`` is
a check that gets ignored.

For a tool mureo does not own, the NAME is a guess and the tool's own
``annotations.readOnlyHint`` is a declaration, so the declaration wins in
both directions: a read-shaped name that declared ``readOnlyHint=False``
is a mutation (and gets its learning-period verdict), a mutation-shaped
name that declared ``readOnlyHint=True`` is a read (and is not refused
spuriously). The name is consulted only when nothing was declared. This
mirrors :func:`mureo.mcp.plugin_semantics._is_read` — same precedence, so
the two surfaces cannot drift apart on "is this a read?". The declarations
reach this pure layer through the registry in
:mod:`mureo.policy.declarations`, populated by ``mureo.mcp.server``.

What believing a declaration costs, stated rather than glossed: a plugin
that declares ``readOnlyHint=True`` on a real mutation is taken at its
word and escapes the ``block_learning_resets`` refusal, where the older
name-only rule would have caught a ``delete_``-shaped name. That is
accepted, for two reasons. Believing a declaration in one direction only
is the same name-beats-declaration inversion this ordering exists to
remove, and it would hard-refuse honest plugin reads whose names merely
look like mutations. And these guardrails defend the operator against an
agent's mistakes, not against an installed plugin that lies about itself:
such a plugin already escapes the money pattern-fallback scan the same way
(#517), and runs arbitrary code besides.
"""
if is_mutating_builtin_tool(tool_name):
return True
if tool_name.startswith(_BUILTIN_PREFIXES):
return False
hint = declared_read_only_hint(tool_name)
if hint is not None:
return not hint
return not is_read_only_tool_name(tool_name)


Expand Down
6 changes: 6 additions & 0 deletions mureo/policy/strategy_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,10 +113,13 @@
BudgetDeclaration,
bid_declaration_for,
budget_declaration_for,
declared_read_only_hint,
register_bid_declaration,
register_budget_declaration,
register_read_only_hint,
reset_bid_declarations,
reset_budget_declarations,
reset_read_only_hints,
)

# The learning-period pre-flight (#548) — "is this change reset-triggering,
Expand Down Expand Up @@ -163,6 +166,9 @@
"register_bid_declaration",
"reset_budget_declarations",
"reset_bid_declarations",
"declared_read_only_hint",
"register_read_only_hint",
"reset_read_only_hints",
"_BUDGET_DECLARATIONS",
"_BID_DECLARATIONS",
"_UNREADABLE",
Expand Down
Loading