Skip to content

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

@vishu-bh

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.

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

Status

Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions