Pydantic KeyError when MCPServerAdapter loads MS-365 tools (deep $ref in Message.bccRecipients)

Message body

### TL;DR
When CrewAI (crewai_tools 0.18.0) connects to the **@softeria/ms-365-mcp-server**
via `MCPServerAdapter(StdioServerParameters)`, the adapter crashes while
building the tool descriptions:

KeyError: ‘#/properties/body/properties/Message/allOf/1/properties/bccRecipients/items’

— i.e. inside `pydantic.json_schema._add_json_refs`.

I already patched the leading-underscore field problem (`__top`, `__filter`, …),
but this second crash still kills the adapter.  
Has anyone found a clean workaround other than installing an unreleased
Pydantic build or skipping the mail tools?

---

### Environment
* Python 3.12  
* crewai == 0.120.1  
* crewai_tools == 0.45.0  
* mcpadapt == 0.1.7  
* pydantic == 2.4.2
* Node MCP server: `@softeria/ms-365-mcp-server 0.4.3`

---

### Minimal repro script

```python
#!/usr/bin/env python
import os, logging
from dotenv import load_dotenv
import patch_mcp_fields                 # <- my underscore-alias patch
from crewai import Agent, Task, Crew, Process, LLM
from crewai_tools import MCPServerAdapter
from mcp import StdioServerParameters
from mcpadapt.crewai_adapter import CrewAIAdapter

# --- 1) hot-patch CrewAIAdapter to swallow NameError (works) -------------
# (see bottom of post for code of patch_mcp_fields)
_real_adapt = CrewAIAdapter.adapt
def _patched_adapt(self, func, mcp_tool):
    tool = _real_adapt(self, func, mcp_tool)
    # wrap _generate_description to survive KeyError --  DOES **NOT** help
    orig = tool._generate_description
    def safe_desc():
        try: orig()
        except KeyError:                                  # <- never reached
            tool.description = f"{tool.name}({', '.join(tool.args_schema)})"
    tool._generate_description = safe_desc
    return tool
CrewAIAdapter.adapt = _patched_adapt
# -------------------------------------------------------------------------

load_dotenv()
llm = LLM(model=os.getenv("OPENAI_MODEL_NAME"),
          base_url=os.getenv("OPENAI_API_BASE"),
          api_key=os.getenv("OPENAI_API_KEY"))

server_params = StdioServerParameters(
    command="npx",
    args=["-y", "@softeria/ms-365-mcp-server"],
    env={"UV_PYTHON": "3.12", **os.environ},
)

with MCPServerAdapter(server_params) as tools:            # <-- CRASHES HERE
    print("Connected, tools:", [t.name for t in tools])

Full traceback (cut to the interesting part)

…
  File ".../mcpadapt/crewai_adapter.py", line 74, in _generate_description
    self.args_schema.model_json_schema()
  File ".../pydantic/json_schema.py", line 2312, in _add_json_refs
    _add_json_refs(v)
  File ".../pydantic/json_schema.py", line 2296, in _add_json_refs
    defs_ref = self.json_to_defs_refs[json_ref]
KeyError: '#/properties/body/properties/Message/allOf/1/properties/bccRecipients/items'

What I’ve tried

Attempt Result
Underscore-alias patch (__top → top, alias=“__top”) :white_check_mark: fixes the earlier NameError
Wrapping BaseTool._generate_description / CrewAIAdapter.adapt :cross_mark: never reached (exception fires inside model_json_schema)
Downgrading to Pydantic 1.10.x :white_check_mark: works but other libs now require v2
Installing pydantic@main (PR #9863) :white_check_mark: works, but it’s unreleased
Filtering out mail tools (`include_tools=[r”^(?!(list-mail get-mail)).*$”]`)

Questions for the CrewAI devs / community

  1. Is there a sanctioned way to tell CrewAI not to callmodel_json_schema() when building tool descriptions?
  2. Would you accept a PR that wraps the call in a safe-guard until the nextPydantic release?
  3. Any other work-around people are using in production?

Thanks!


patch_mcp_fields.py

(underscore fix)

import re, inspect
from mcpadapt.utils import modeling as _m

def _scrub(schema):
    if isinstance(schema, dict) and "properties" in schema:
        schema["properties"] = {
            (re.sub(r"^_+", "", k) or "field"): {**v, "alias": k} if k.startswith("_") else v
            for k, v in schema["properties"].items()
        }
    return schema

def _wrap(fn):
    def inner(*args, **kw):
        if len(args) == 1:                       # (schema)
            return fn(_scrub(args[0]), **kw)
        name, schema = args                     # (name, schema)
        return fn(name, _scrub(schema), **kw)
    return inner

target = "process_schema" if hasattr(_m, "process_schema") else "create_model_from_json_schema"
setattr(_m, target, _wrap(getattr(_m, target)))

Feel free to cut or expand portions—this should be enough for the maintainers to reproduce and diagnose.


Screenshots / attachments