From 7d902b853cdd6a9614c8d56aa4198dc04f10e308 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Thu, 9 Jul 2026 13:43:30 -0400 Subject: [PATCH 1/3] fix(community-v3): inject tool context without deep-copying the tool When enriching a tool's input schema with the AgentCat `context` parameter, the FastMCP v3 middleware deep-copied the entire tool object on every `tools/list` request. FastMCP v3 tools generated from an OpenAPI specification hold a reference to their `httpx.AsyncClient`, which contains a `threading.RLock`. Deep-copying that object raised `TypeError: cannot pickle '_thread.RLock' object`, and the resulting fallback returned the tool unmodified. As a consequence, context injection was silently skipped for every affected tool and the failure was logged on each request, producing a high volume of diagnostic errors. Only the `parameters` schema (a plain dictionary) is modified during injection, so this change copies just that schema and reuses the rest of the tool by reference via `Tool.model_copy`. This avoids traversing non-copyable fields entirely, restores context injection for OpenAPI-generated tools, and leaves the server's original tool objects unmutated. The remaining copy-failure log line now records the resolved FastMCP version to aid future diagnosis. Adds regression coverage that reproduces the RLock failure with an OpenAPI-generated tool and verifies injection, non-mutation, and the version-tagged log message. Verified against FastMCP 3.0.0b1 and 3.4.4. --- .../overrides/community_v3/middleware.py | 23 +++- .../test_community_v3_inject_context.py | 110 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 tests/community/test_community_v3_inject_context.py diff --git a/src/agentcat/modules/overrides/community_v3/middleware.py b/src/agentcat/modules/overrides/community_v3/middleware.py index b597be4..f4164eb 100644 --- a/src/agentcat/modules/overrides/community_v3/middleware.py +++ b/src/agentcat/modules/overrides/community_v3/middleware.py @@ -9,6 +9,7 @@ import copy from collections.abc import Sequence from datetime import datetime, timezone +from importlib.metadata import version from typing import TYPE_CHECKING, Any import mcp.types as mt @@ -35,6 +36,14 @@ from fastmcp.tools.tool import Tool, ToolResult +def _fastmcp_version() -> str: + """Best-effort resolved fastmcp version for diagnostics; never throws.""" + try: + return version("fastmcp") + except Exception: + return "unknown" + + class AgentCatMiddleware: """Middleware for AgentCat tracking in FastMCP v3. @@ -387,10 +396,20 @@ def _inject_context_into_tools(self, tools: list[Tool]) -> list[Tool]: modified_tools.append(tool) continue + # Only the parameters schema (a plain dict) is mutated below, so copy + # just that — never the whole Tool. Deep-copying the Tool drags in + # non-picklable fields it may hold (e.g. an httpx.AsyncClient with a + # threading.RLock on OpenAPI-generated tools), which raised + # "cannot pickle '_thread.RLock' object" and silently dropped context + # injection on every tools/list. try: - tool_copy = copy.deepcopy(tool) + tool_copy = tool.model_copy( + update={"parameters": copy.deepcopy(getattr(tool, "parameters", None))} + ) except Exception as e: - write_to_log(f"Error copying tool {tool.name}: {e}") + write_to_log( + f"Error copying tool {tool.name} (fastmcp {_fastmcp_version()}): {e}" + ) modified_tools.append(tool) continue diff --git a/tests/community/test_community_v3_inject_context.py b/tests/community/test_community_v3_inject_context.py new file mode 100644 index 0000000..438d71c --- /dev/null +++ b/tests/community/test_community_v3_inject_context.py @@ -0,0 +1,110 @@ +"""Regression tests for context injection in the FastMCP v3 middleware. + +Root cause reproduced here: OpenAPI-generated FastMCP v3 tools hold a reference +to an ``httpx.AsyncClient``, which contains a ``threading.RLock``. The old +implementation did ``copy.deepcopy(tool)`` to inject the ``context`` parameter, +which raised ``TypeError: cannot pickle '_thread.RLock' object`` for every such +tool on every ``tools/list`` — silently dropping context injection and flooding +diagnostics with errors (observed for proj_3E07PMEFqZoF9sc6QeWvoaNbpet). +""" + +import asyncio +from datetime import datetime, timezone + +import copy +import httpx +import pytest +from fastmcp import FastMCP + +from agentcat.modules.overrides.community_v3 import middleware as v3_middleware +from agentcat.modules.overrides.community_v3.middleware import AgentCatMiddleware +from agentcat.types import AgentCatData, AgentCatOptions, SessionInfo + + +def _make_data() -> AgentCatData: + return AgentCatData( + project_id="test_project", + session_id="test_session", + session_info=SessionInfo(client_name="TestClient", client_version="1.0.0"), + last_activity=datetime.now(timezone.utc), + options=AgentCatOptions(custom_context_description="Why are you doing this?"), + ) + + +def _openapi_tool(): + """An OpenAPI-generated tool holding an httpx client (non-deepcopyable).""" + spec = { + "openapi": "3.0.0", + "info": {"title": "rootly", "version": "1"}, + "paths": { + "/severities": { + "get": { + "operationId": "list_severities", + "summary": "List severities", + "responses": {"200": {"description": "ok"}}, + } + } + }, + } + client = httpx.AsyncClient(base_url="https://example.com") + server = FastMCP.from_openapi(openapi_spec=spec, client=client, name="rootly") + tools = server.list_tools() + if asyncio.iscoroutine(tools): + tools = asyncio.run(tools) + return server, tools[0] + + +def test_openapi_tool_is_not_deepcopyable(): + """Guard: confirms the repro condition (deepcopy raises on the RLock).""" + _server, tool = _openapi_tool() + with pytest.raises(TypeError, match="cannot pickle '_thread.RLock' object"): + copy.deepcopy(tool) + + +def test_context_injected_into_openapi_tool(): + """Context injection must succeed even for non-deepcopyable tools.""" + server, tool = _openapi_tool() + middleware = AgentCatMiddleware(_make_data(), server) + + result = middleware._inject_context_into_tools([tool]) + + assert len(result) == 1 + params = result[0].parameters + assert "context" in params["properties"], "context was not injected" + assert "context" in params["required"] + assert ( + params["properties"]["context"]["description"] == "Why are you doing this?" + ) + + +def test_original_tool_not_mutated(): + """Injection must not mutate the server's original tool object.""" + server, tool = _openapi_tool() + middleware = AgentCatMiddleware(_make_data(), server) + + middleware._inject_context_into_tools([tool]) + + assert "context" not in (tool.parameters or {}).get("properties", {}) + + +def test_copy_failure_logs_fastmcp_version(monkeypatch): + """If a tool still can't be copied, the log names the fastmcp version.""" + from importlib.metadata import version + + server, tool = _openapi_tool() + middleware = AgentCatMiddleware(_make_data(), server) + + def _boom(*args, **kwargs): + raise RuntimeError("copy blew up") + + monkeypatch.setattr(v3_middleware.copy, "deepcopy", _boom) + + logged: list[str] = [] + monkeypatch.setattr(v3_middleware, "write_to_log", lambda msg: logged.append(msg)) + + result = middleware._inject_context_into_tools([tool]) + + assert result == [tool] # falls back to the untouched original + assert len(logged) == 1 + assert f"fastmcp {version('fastmcp')}" in logged[0] + assert "list_severities" in logged[0] From 5256af57322ba1c62a2c17847253351eb08354f4 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Thu, 9 Jul 2026 13:43:30 -0400 Subject: [PATCH 2/3] chore(release): bump version to 1.0.1 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cddbfa0..66a830f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agentcat" -version = "1.0.0" +version = "1.0.1" description = "Analytics tool for MCP (Model Context Protocol) servers and AI agents - tracks tool usage patterns and provides insights" authors = [ { name = "AgentCat, Inc.", email = "support@agentcat.com" }, From b33f9ac00a3c8d6740e56f7ea896daf47a9e9687 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Thu, 9 Jul 2026 13:47:43 -0400 Subject: [PATCH 3/3] test(community-v3): skip OpenAPI middleware tests when FastMCP v3 is absent The regression tests exercise the FastMCP v3 OpenAPI middleware path and require FastMCP v3+. Guard the module so it is skipped rather than failing collection in the no-FastMCP CI job and on the FastMCP v2 compatibility matrix: defer the fastmcp/httpx imports behind a try/except and gate the module with a skipif on the resolved FastMCP major version. --- .../test_community_v3_inject_context.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/community/test_community_v3_inject_context.py b/tests/community/test_community_v3_inject_context.py index 438d71c..47aaca1 100644 --- a/tests/community/test_community_v3_inject_context.py +++ b/tests/community/test_community_v3_inject_context.py @@ -12,14 +12,29 @@ from datetime import datetime, timezone import copy -import httpx import pytest -from fastmcp import FastMCP from agentcat.modules.overrides.community_v3 import middleware as v3_middleware from agentcat.modules.overrides.community_v3.middleware import AgentCatMiddleware from agentcat.types import AgentCatData, AgentCatOptions, SessionInfo +# The community_v3 middleware and FastMCP.from_openapi are FastMCP v3+ only. Skip +# this module entirely when FastMCP is absent (test-without-fastmcp job) or on the +# v2 compatibility matrix, without importing fastmcp at module top level. +try: + import httpx + import fastmcp + from fastmcp import FastMCP + + HAS_FASTMCP_V3 = int(fastmcp.__version__.split(".")[0]) >= 3 +except Exception: # pragma: no cover - import guard + HAS_FASTMCP_V3 = False + +pytestmark = pytest.mark.skipif( + not HAS_FASTMCP_V3, + reason="Requires FastMCP v3+ (community_v3 OpenAPI middleware path)", +) + def _make_data() -> AgentCatData: return AgentCatData(