Skip to content

[BUG]: CopyOnWriteDict equality causes empty hook arg modifications to be dropped聽#54

Closed

Description

@vishu-bh

馃悶 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:

  • mcpgateway - API
  • mcpgateway - UI (admin panel)
  • mcpgateway.wrapper - stdio wrapper
  • Federation or Transports
  • CLI, Makefiles, or shell scripts
  • Container setup (Docker/Podman/Compose)
  • Other (explain below)

CPEX framework:

  • cpex/framework/memory.py - CopyOnWriteDict
  • cpex/framework/hooks/policies.py - apply_policy

馃攣 Steps to Reproduce

  1. 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",
})
  1. 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
  1. 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:

new_val == old_val

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:

args={}

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.

Activity

  1. moved this from Backlog to In progress in CPEXon May 28, 2026
  2. moved this from In progress to In review in CPEXon May 28, 2026
  3. moved this from In review to Done in CPEXon May 29, 2026
  4. added this to the 0.1.1 milestone on Jun 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

  • Status
    Done

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions