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 [] != cow
For 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 CopyOnWriteList wrapping 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 the items field, it evaluates new_val == old_val, where new_val == [] and old_val == CopyOnWriteList(["tool-a", "tool-b", "tool-c"]). Because CopyOnWriteList.__eq__ is not implemented and the base list storage is empty until _materialize() runs, the comparison returns True. apply_policy() then treats the field as unchanged and omits it from updates. If no other field changed, apply_policy() returns None, which drops the plugin's modified_payload and the gateway serves the caller the original, unfiltered items.
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 in main source structure
- Runtime: Python 3.13
- OS: Linux (also reproduced in a containerized
mcp-context-forge deployment)
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.
Current Behavior
CopyOnWriteListcompares equal to[]even when it contains data from its wrapped_originallist. This causes hook payload policy handling to incorrectly drop a validmodified_payloadwhen a plugin filters a list down to zero items.Reproduced from
mcp-context-forgewhile building atool_post_listauthorization 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 returnsToolPostListResult(modified_payload=ToolPostListPayload(items=[])), but CPEX treats the modified payload as "no effective change" and returnsmodified_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) andcpex/framework/hooks/policies.py(apply_policy).Expected Behavior
CopyOnWriteListequality should compare the materialized logical sequence, not the empty baseliststorage.For hook policy handling, if a plugin changes
items=["tool-a", "tool-b", "tool-c"]toitems=[], CPEX should treat that as an alloweditemsmodification and preservemodified_payload, instead of silently discarding it.Steps to Reproduce
Create a
CopyOnWriteListwrapping a non-empty original list, without ever writing to it:Compare it to an empty list:
Actual output:
Reproduce through hook policy handling:
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):
This manifested downstream as an authorization plugin's deny-everything decision being silently discarded: the gateway logged the plugin's own
kept=0filtering 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
cpex==0.1.1; also present inmainsource structuremcp-context-forgedeployment)Additional Context (optional)
This is the same defect class as #54 (
CopyOnWriteDictequality causes empty hook arg modifications to be dropped), fixed in #55 — but that fix only covered theDictvariant.CopyOnWriteListwas never given the same treatment and still lacks__eq__/__ne__today.