🐞 Bug Summary
CopyOnWriteDict compares equal to {} even when it contains data from its wrapped _original mapping. This causes hook payload policy handling to incorrectly drop a valid modified_payload when a plugin removes all arguments from a payload.
This was reproduced from mcp-context-forge while debugging a tool_pre_invoke plugin. The plugin correctly returned ToolPreInvokeResult(modified_payload=ToolPreInvokePayload(args={...})), but when the plugin stripped all WXO-specific arguments and the final args became {}, CPEX treated the modified payload as "no effective change" and returned modified_payload=None to the gateway.
🧩 Affected Component
Select the area of the project impacted:
CPEX framework:
cpex/framework/memory.py - CopyOnWriteDict
cpex/framework/hooks/policies.py - apply_policy
🔁 Steps to Reproduce
- Create a
CopyOnWriteDict with data only in the wrapped original mapping:
from cpex.framework.memory import CopyOnWriteDict
cow = CopyOnWriteDict({
"wxo_connection_id": "",
"wxo_auth": "fake-token",
"wxo_environment_id": "draft",
})
- Compare it to an empty dict:
print(dict(cow))
print(len(cow))
print(cow == {})
print(cow == {
"wxo_connection_id": "",
"wxo_auth": "fake-token",
"wxo_environment_id": "draft",
})
Actual output:
{'wxo_connection_id': '', 'wxo_auth': 'fake-token', 'wxo_environment_id': 'draft'}
3
True
False
- Reproduce through hook policy handling:
from cpex.framework import ToolPreInvokePayload, ToolPreInvokeResult
from cpex.framework.hooks.http import HttpHeaderPayload
original_payload = ToolPreInvokePayload(
name="list_all_secrets",
args=CopyOnWriteDict({
"wxo_connection_id": "",
"wxo_auth": "fake-token",
"wxo_environment_id": "draft",
}),
headers=HttpHeaderPayload(root={}),
)
modified_payload = ToolPreInvokePayload(
name="list_all_secrets",
args={},
headers=HttpHeaderPayload(root={}),
)
When apply_policy() compares the args field, it evaluates:
where:
new_val == {}
old_val == CopyOnWriteDict({"wxo_connection_id": "", "wxo_auth": "...", "wxo_environment_id": "draft"})
Because CopyOnWriteDict.__eq__ is not implemented and the base dict storage is empty, 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.
🤔 Expected Behavior
CopyOnWriteDict equality should compare the materialized logical mapping, not the empty base dict storage.
Expected:
cow = CopyOnWriteDict({"wxo_connection_id": "", "wxo_auth": "fake-token"})
assert cow != {}
assert cow == {"wxo_connection_id": "", "wxo_auth": "fake-token"}
assert {} != cow
For hook policy handling, if a plugin changes:
args={"wxo_connection_id": "", "wxo_auth": "...", "wxo_environment_id": "draft"}
to:
CPEX should treat that as an allowed args modification and preserve modified_payload.
📓 Logs / Error Output
From a local make dev reproduction in mcp-context-forge with a minimal tool_pre_invoke plugin that strips wxo_* args:
Invoking tool: wxo-empty-args-repro with arguments:
dict_keys(['wxo_connection_id', 'wxo_auth', 'wxo_environment_id'])
REPRO_PLUGIN input args:
CopyOnWriteDict({'wxo_connection_id': '', 'wxo_auth': 'fake-token', 'wxo_environment_id': 'draft'})
REPRO_PLUGIN cleaned args:
{}
tool_pre_invoke completed for wxo-empty-args-repro:
modified_payload=False,
arg_keys_before=['wxo_auth', 'wxo_connection_id', 'wxo_environment_id'],
header_keys_before=[]
Direct Python check against the installed CPEX package:
repr: CopyOnWriteDict({'wxo_connection_id': '', 'wxo_auth': 'fake', 'wxo_environment_id': 'draft'})
items: [('wxo_connection_id', ''), ('wxo_auth', 'fake'), ('wxo_environment_id', 'draft')]
len: 3
dict(cow): {'wxo_connection_id': '', 'wxo_auth': 'fake', 'wxo_environment_id': 'draft'}
copy: {'wxo_connection_id': '', 'wxo_auth': 'fake', 'wxo_environment_id': 'draft'}
cow == {}: True
{} == cow: True
cow == {'wxo_connection_id':'','wxo_auth':'fake','wxo_environment_id':'draft'}: False
This manifested downstream as unexpected keyword arguments being forwarded to the upstream MCP tool because the gateway never received the plugin's cleaned args={} payload:
3 validation errors for call[list_all_secrets]
wxo_connection_id
Unexpected keyword argument
wxo_auth
Unexpected keyword argument
wxo_environment_id
Unexpected keyword argument
🧠 Environment Info
| Key |
Value |
| Version or commit |
cpex installed from current dependency in mcp-context-forge dev environment; also present in main source structure |
| Runtime |
Python 3.13 |
| Platform / OS |
macOS |
| Container |
none for local repro; original customer/staging report was containerized |
🧩 Additional Context (optional)
Root cause appears to be that CopyOnWriteDict subclasses dict, stores original data in _original, and initializes the parent dict as empty:
super().__init__()
self._original = original
It overrides read/iteration helpers like __getitem__, __contains__, __len__, __iter__, items(), copy(), and __repr__, but does not override __eq__ / __ne__. Python's dict equality therefore compares the empty base dict storage instead of the logical materialized mapping.
Suggested fix in CopyOnWriteDict:
from collections.abc import Mapping
def __eq__(self, other: Any) -> bool:
if isinstance(other, Mapping):
return dict(self.items()) == dict(other.items())
return NotImplemented
def __ne__(self, other: Any) -> bool:
eq = self.__eq__(other)
if eq is NotImplemented:
return NotImplemented
return not eq
Suggested regression tests:
def test_copy_on_write_dict_equality_uses_materialized_items():
cow = CopyOnWriteDict({
"wxo_connection_id": "",
"wxo_auth": "fake-token",
"wxo_environment_id": "draft",
})
assert cow != {}
assert {} != cow
assert cow == {
"wxo_connection_id": "",
"wxo_auth": "fake-token",
"wxo_environment_id": "draft",
}
def test_apply_policy_preserves_empty_args_modification():
# A tool_pre_invoke plugin may legitimately strip framework-only args,
# leaving args as an empty dict. That must still be treated as a change.
...
This is especially visible for tools that have no real user arguments. If the original tool call contains only framework/plugin metadata args such as wxo_*, the plugin cleans them to {} and the policy layer drops the update. If at least one non-WXO arg remains, for example {"repoName": "...", "wxo_auth": "..."} becoming {"repoName": "..."}, the modified payload is preserved.
🐞 Bug Summary
CopyOnWriteDictcompares equal to{}even when it contains data from its wrapped_originalmapping. This causes hook payload policy handling to incorrectly drop a validmodified_payloadwhen a plugin removes all arguments from a payload.This was reproduced from
mcp-context-forgewhile debugging atool_pre_invokeplugin. The plugin correctly returnedToolPreInvokeResult(modified_payload=ToolPreInvokePayload(args={...})), but when the plugin stripped all WXO-specific arguments and the final args became{}, CPEX treated the modified payload as "no effective change" and returnedmodified_payload=Noneto the gateway.🧩 Affected Component
Select the area of the project impacted:
mcpgateway- APImcpgateway- UI (admin panel)mcpgateway.wrapper- stdio wrapperCPEX framework:
cpex/framework/memory.py-CopyOnWriteDictcpex/framework/hooks/policies.py-apply_policy🔁 Steps to Reproduce
CopyOnWriteDictwith data only in the wrapped original mapping:Actual output:
When
apply_policy()compares theargsfield, it evaluates:where:
Because
CopyOnWriteDict.__eq__is not implemented and the basedictstorage is empty, 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_payload.🤔 Expected Behavior
CopyOnWriteDictequality should compare the materialized logical mapping, not the empty basedictstorage.Expected:
For hook policy handling, if a plugin changes:
to:
CPEX should treat that as an allowed
argsmodification and preservemodified_payload.📓 Logs / Error Output
From a local
make devreproduction inmcp-context-forgewith a minimaltool_pre_invokeplugin that stripswxo_*args:Direct Python check against the installed CPEX package:
This manifested downstream as unexpected keyword arguments being forwarded to the upstream MCP tool because the gateway never received the plugin's cleaned
args={}payload:🧠 Environment Info
cpexinstalled from current dependency inmcp-context-forgedev environment; also present inmainsource structure🧩 Additional Context (optional)
Root cause appears to be that
CopyOnWriteDictsubclassesdict, stores original data in_original, and initializes the parent dict as empty:It overrides read/iteration helpers like
__getitem__,__contains__,__len__,__iter__,items(),copy(), and__repr__, but does not override__eq__/__ne__. Python's dict equality therefore compares the empty base dict storage instead of the logical materialized mapping.Suggested fix in
CopyOnWriteDict:Suggested regression tests:
This is especially visible for tools that have no real user arguments. If the original tool call contains only framework/plugin metadata args such as
wxo_*, the plugin cleans them to{}and the policy layer drops the update. If at least one non-WXO arg remains, for example{"repoName": "...", "wxo_auth": "..."}becoming{"repoName": "..."}, the modified payload is preserved.