perf: lazy-load mcp and jsonschema to reduce import time by reginaldalfret · Pull Request #1424 · anthropics/claude-agent-sdk-python · GitHub
Skip to content

perf: lazy-load mcp and jsonschema to reduce import time - #1424

Open
reginaldalfret wants to merge 3 commits into
anthropics:mainfrom
reginaldalfret:fix/lazy-load-mcp
Open

reginaldalfret wants to merge 3 commits into
anthropics:mainfrom
reginaldalfret:fix/lazy-load-mcp

Conversation

@reginaldalfret

Copy link
Copy Markdown

Summary

Addresses #1386.

Importing \claude_agent_sdk\ previously imported \mcp\ (and \jsonschema) unconditionally at module import time. On typical environments, importing \mcp\ takes ~0.9–1.2 seconds, which constitutes >80% of total \claude_agent_sdk\ import duration. Callers who only use \query(), \ClaudeSDKClient, or session management without defining in-process SDK MCP servers were forced to incur this startup overhead on every process spawn.

Changes

  1. Lazy ToolAnnotations:

    • Defer \ToolAnnotations\ creation via module-level _getattr_\ and _dir_\ (PEP 562).
    • When accessed (\rom claude_agent_sdk import ToolAnnotations\ or \claude_agent_sdk.ToolAnnotations), \ToolAnnotations\ is instantiated as a subclass of \mcp.types.ToolAnnotations, caching it in globals with full wire-alias translation and docstring preserved.
    • Retain full static typing under \if TYPE_CHECKING:.
  2. Deferred Server Construction:

    • Defer imports of \jsonschema, \�uild_tool_server, \CallToolResult, and \Tool\ to the execution of \create_sdk_mcp_server().
    • Tool definition via @tool\ remains lightweight and does not trigger \mcp\ imports.
  3. Deferred MCP Bridge in Query:

    • In \Query.init, only import \SdkMcpBridge\ when \sdk_mcp_servers\ is provided.
  4. Regression Tests:

    • Added \ ests/test_lazy_import.py\ verifying in isolated subprocesses that:
      • \import claude_agent_sdk\ does not load \mcp, \jsonschema, _mcp_compat, or \sdk_mcp_bridge.
      • Using @tool\ decorator does not load \mcp.
      • \Query\ instantiation without MCP servers does not load \mcp.
      • \ToolAnnotations\ and \create_sdk_mcp_server\ load \mcp\ on demand and function correctly.
      • \dir(claude_agent_sdk)\ surfaces all exports.

Test plan

  • Full test suite passes: \1619 passed, 21 skipped in 25.35s\ (all 6 new unit tests pass).

  • uff check src tests\ passed with zero errors.

  • uff format --check src tests\ passed with zero errors.
  • \mypy src\ passed with zero issues across 26 source files.
  • Measured cold import time dropped from ~1.44s to ~0.30s.

Closes #1386

Importing claude_agent_sdk previously imported mcp and jsonschema at
module initialization time, adding ~1+ seconds of startup latency even
for callers only using query(), ClaudeSDKClient, or session utilities
without in-process SDK MCP servers.

- Defer mcp and jsonschema imports in __init__.py until create_sdk_mcp_server()
  is called.
- Dynamically define and cache ToolAnnotations via module-level __getattr__
  on first access, preserving type annotations, docstring, and wire alias
  resolution.
- Import SdkMcpBridge in Query.__init__ only when sdk_mcp_servers is non-empty.
- Add tests/test_lazy_import.py verifying subprocess import isolation and
  on-demand lazy loading.

Closes anthropics#1386

Signed-off-by: reginaldalfret <reginaldalfret@gmail.com>

@Somay-kousis Somay-kousis left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran this at dd88abe against merge base dd27b70 (Python 3.12, repo .venv). The new tests pass on head: test_lazy_import + test_sdk_mcp_integration + test_mcp_large_output, 166 passed. With src reverted, the 6 lazy-import tests fail, as expected. import claude_agent_sdk no longer loads mcp or jsonschema. Two public objects no longer work under standard introspection.

1. typing.get_type_hints() raises on SdkMcpTool and tool.
from __future__ import annotations (__init__.py:3) turns the annotations at __init__.py:298 (SdkMcpTool.annotations) and __init__.py:305 (tool(..., annotations=...)) into strings that name _McpToolAnnotations. That name now exists only under TYPE_CHECKING (__init__.py:30-32). get_type_hints resolves names against the module __dict__, and module __getattr__ (__init__.py:679) is never consulted for that. This fails even after mcp has been loaded (ToolAnnotations accessed and a server created):

head: get_type_hints(SdkMcpTool) -> NameError name '_McpToolAnnotations' is not defined
      get_type_hints(tool)       -> NameError name '_McpToolAnnotations' is not defined
base: get_type_hints(SdkMcpTool) -> {... 'annotations': mcp ToolAnnotations | None}

Tools that resolve hints through get_type_hints (sphinx-autodoc-typehints, typeguard) hit this. One fix: in _make_tool_annotations_class() and in the _McpToolAnnotations branch of __getattr__, write both ToolAnnotations and _McpToolAnnotations into globals(). That makes hints resolve once mcp is loaded. A test that calls get_type_hints(SdkMcpTool) after create_sdk_mcp_server() would lock it in.

2. ToolAnnotations instances no longer pickle.
The class is now defined inside _make_tool_annotations_class() (__init__.py:255-287). __module__ is reset, but __qualname__ stays _make_tool_annotations_class.<locals>.ToolAnnotations:

head: pickle.dumps(ToolAnnotations(readOnlyHint=True)) -> AttributeError Can't get local object '_make_tool_annotations_class.<locals>.ToolAnnotations'
base: round-trips

Adding ToolAnnotations.__qualname__ = "ToolAnnotations" next to the __module__ line fixes it. I checked by setting it at runtime on head: the round-trip then succeeds, because pickle looks up claude_agent_sdk.ToolAnnotations and gets the cached class. This matters for multiprocessing and for anything that caches tool definitions.

Not checked: mcp 1.x (ran with the repo's installed mcp only), Python 3.10/3.11, mypy and IDE behaviour under the new TYPE_CHECKING class body, from claude_agent_sdk import *.

Signed-off-by: reginaldalfret <reginaldalfret@gmail.com>
@reginaldalfret

Copy link
Copy Markdown
Author

@Somay-kousis

Copy link
Copy Markdown

Reran on bd72125: ToolAnnotations pickles, and once mcp is loaded get_type_hints(SdkMcpTool) and get_type_hints(tool) resolve. Thanks.

One case is left: before anything loads mcp, both still raise NameError: name '_McpToolAnnotations' is not defined, since the name only enters the module dict when the class is built. That is the order sphinx-autodoc-typehints or a typeguard import hook hits, and it worked on main because mcp loaded eagerly. Calling _make_tool_annotations_class() from the module __getattr__ isn't enough there, because get_type_hints reads the module dict directly.

…hints before mcp load

Signed-off-by: reginaldalfret <reginaldalfret@gmail.com>
@reginaldalfret

Copy link
Copy Markdown
Author

@Somay-kousis Thanks for catching that import-hook / autodoc edge case!

In commit 0287cca, I've resolved this by providing a lightweight runtime _McpToolAnnotations placeholder at module initialization (if not TYPE_CHECKING) with module = "mcp.types" and name = "ToolAnnotations". When MCP is subsequently loaded via _make_tool_annotations_class(), it seamlessly updates globals()["_McpToolAnnotations"] to the real class from mcp.types.

  1. yping.get_type_hints(SdkMcpTool) and yping.get_type_hints(tool) now resolve immediately upon import claude_agent_sdk without raising NameError.
  2. Crucially, mcp remains unloaded ("mcp" not in sys.modules) until MCP features or ToolAnnotations are actually used.
  3. Added est_get_type_hints_resolves_before_mcp_loaded to ests/test_lazy_import.py to prevent regressions. All 169 unit and integration tests are passing cleanly.

@Somay-kousis

Copy link
Copy Markdown

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lazy-load mcp to cut import claude_agent_sdk from ~1 s to ~0.1 s

2 participants