Repository navigation
[BUG]: CopyOnWriteList compares equal to [], silently discarding empty-list hook payload modifications #135
Description
Current Behavior
CopyOnWriteList compares equal to [] even when it contains data from its wrapped _original list. This causes hook payload policy handling to incorrectly drop a valid modified_payload when a plugin filters a list down to zero items.
Reproduced from mcp-context-forge while building a tool_post_list authorization plugin that filters a tool listing by the caller's IdP group membership. When a caller's groups grant them zero of the listed tools, the plugin correctly returns ToolPostListResult(modified_payload=ToolPostListPayload(items=[])), but CPEX treats the modified payload as "no effective change" and returns modified_payload=None — the gateway then falls back to serving the original, unfiltered list to a caller who should see nothing. Fail-open on exactly the case an authorization filter most needs to enforce.
Affected component: CPEX framework — cpex/framework/memory.py (CopyOnWriteList) and cpex/framework/hooks/policies.py (apply_policy).
Expected Behavior
CopyOnWriteList equality should compare the materialized logical sequence, not the empty base list storage.
cow = CopyOnWriteList(["tool-a", "tool-b", "tool-c"])
assert cow != []
assert cow == ["tool-a", "tool-b", "tool-c"]
assert [] != cowFor hook policy handling, if a plugin changes items=["tool-a", "tool-b", "tool-c"] to items=[], CPEX should treat that as an allowed items modification and preserve modified_payload, instead of silently discarding it.
Steps to Reproduce
-
Create a
CopyOnWriteListwrapping a non-empty original list, without ever writing to it:from cpex.framework.memory import CopyOnWriteList cow = CopyOnWriteList(["tool-a", "tool-b", "tool-c"])
-
Compare it to an empty list:
print(list(cow)) print(len(cow)) print(cow == []) print([] == cow)
Actual output:
['tool-a', 'tool-b', 'tool-c'] 3 True True -
Reproduce through hook policy handling:
from cpex.framework import ToolPostListPayload, ToolPostListResult original_payload = ToolPostListPayload( server_id="srv-1", items=CopyOnWriteList(["tool-a", "tool-b", "tool-c"]), ) modified_payload = ToolPostListPayload( server_id="srv-1", items=[], )
When
apply_policy()compares theitemsfield, it evaluatesnew_val == old_val, wherenew_val == []andold_val == CopyOnWriteList(["tool-a", "tool-b", "tool-c"]). BecauseCopyOnWriteList.__eq__is not implemented and the baseliststorage is empty until_materialize()runs, the comparison returnsTrue.apply_policy()then treats the field as unchanged and omits it fromupdates. If no other field changed,apply_policy()returnsNone, which drops the plugin'smodified_payloadand the gateway serves the caller the original, unfiltereditems.
Logs / Error Output
Direct Python check against the installed CPEX package (0.1.1):
>>> from cpex.framework.memory import CopyOnWriteList
>>> original = ['tool-a', 'tool-b', 'tool-c']
>>> cow = CopyOnWriteList(original)
>>> len(cow)
3
>>> list(cow)
['tool-a', 'tool-b', 'tool-c']
>>> cow == []
True
>>> [] == cow
True
>>> cow._materialized
False
This manifested downstream as an authorization plugin's deny-everything decision being silently discarded: the gateway logged the plugin's own kept=0 filtering decision, then served the full, unfiltered tool list to the same request — indistinguishable from the plugin not running at all. Confirmed on production traffic across all users and all gated virtual servers for an extended window with zero denials recorded.
Environment
- Version or commit:
cpex==0.1.1; also present inmainsource structure - Runtime: Python 3.13
- OS: Linux (also reproduced in a containerized
mcp-context-forgedeployment)
Additional Context (optional)
This is the same defect class as #54 (CopyOnWriteDict equality causes empty hook arg modifications to be dropped), fixed in #55 — but that fix only covered the Dict variant. CopyOnWriteList was never given the same treatment and still lacks __eq__/__ne__ today.
Activity