Skip to content

Refactor Client class into mixins and add timeout utilities#2933

Merged
jlowin merged 3 commits intomainfrom
deps
Jan 19, 2026
Merged

Refactor Client class into mixins and add timeout utilities#2933
jlowin merged 3 commits intomainfrom
deps

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Jan 19, 2026

The Client class was getting unwieldy. This splits it into focused mixin classes while keeping the public API unchanged:

  • ClientToolsMixin - tool listing and calling
  • ClientPromptsMixin - prompt operations
  • ClientResourcesMixin - resource operations
  • ClientTaskManagementMixin - SEP-1686 task management

Also extracts normalize_timeout_to_timedelta and normalize_timeout_to_seconds to utilities/timeout.py, consolidating duplicated timeout conversion logic from client.py, tools_client.py, and the HTTP transports.

# API unchanged - mixins compose transparently
async with Client(server) as client:
    tools = await client.list_tools()
    result = await client.call_tool("my_tool", {"arg": "value"})

@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. client Related to the FastMCP client SDK or client-side functionality. labels Jan 19, 2026
Split the monolithic Client class into focused mixin classes:
- ClientToolsMixin (tools_client.py)
- ClientPromptsMixin (prompts.py)
- ClientResourcesMixin (resources.py)
- ClientTaskManagementMixin (task_management.py)

Also:
- Extract timeout normalization helpers to utilities/timeout.py
- Split SSETransport into its own module (transports/sse.py)
- Clean up unused imports across client modules
- Fix bare except clauses to catch specific McpError

Note: ty reports unresolved-attribute errors on mixin classes because
it doesn't understand that mixins will be composed with Client. This is
a known limitation of mixin typing in Python. The code is functionally
correct and all tests pass.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9a032c8308

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/utilities/timeout.py Outdated
Comment on lines +41 to +44
if isinstance(value, datetime.timedelta):
return value.total_seconds()
if isinstance(value, int | float):
return float(value)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve init_timeout=0 as disabled timeout

normalize_timeout_to_seconds now converts any numeric value to a float, so init_timeout=0 becomes 0.0 instead of None. Client.__init__ uses this helper for the init handshake timeout and documents “Set to 0 to disable”, but with this normalization the subsequent anyio.fail_after(0.0) will immediately time out, breaking clients that rely on 0 as the sentinel. Consider treating 0 (and possibly timedelta(0)) as None for init timeouts.

Useful? React with 👍 / 👎.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jan 19, 2026

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

Client is refactored to compose functionality via four new mixins (ClientResourcesMixin, ClientPromptsMixin, ClientToolsMixin, ClientTaskManagementMixin), moving many MCP methods out of the core Client. Timeout handling is centralized with normalize_timeout_to_seconds and normalize_timeout_to_timedelta. Transport code is reorganized: StreamableHttpTransport introduced in the http module and SSETransport moved to a dedicated sse module. New mixins implement SEP-1686 task flows (task creation/registration and graceful degradation), and session lifecycle logic and session-monitoring usage are standardized across MCP call sites.

Possibly related PRs

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request description provides a clear explanation of the refactoring rationale and lists the four new mixin classes created. However, it lacks several required sections from the template: issue closure reference, checklist confirmations, and self-review/readiness confirmation. Add required template sections including the issue number closure, all contributor checklist items, and review checklist items to meet repository standards.
Docstring Coverage ⚠️ Warning Docstring coverage is 76.47% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main refactoring work: splitting the Client class into mixins and extracting timeout utilities, which directly aligns with the primary changes in the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Comment thread src/fastmcp/client/prompts.py
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/fastmcp/client/transports/http.py (1)

94-100: Fix runtime error when numeric timeout values are passed to Client.

The Client accepts timeout as datetime.timedelta | float | int | None (line 260), but directly assigns this to read_timeout_seconds in SessionKwargs without normalization (line 290). The http transport then uses cast() (a no-op at runtime) and calls .total_seconds() on what might be a numeric value, causing a TypeError at runtime.

The normalize_timeout_to_timedelta() utility already handles both forms correctly. Apply it in http.py (and the identical pattern in sse.py):

Suggested fix
-        if session_kwargs.get("read_timeout_seconds") is not None:
-            read_timeout_seconds = cast(
-                datetime.timedelta, session_kwargs.get("read_timeout_seconds")
-            )
-            timeout = httpx.Timeout(30.0, read=read_timeout_seconds.total_seconds())
+        read_timeout = normalize_timeout_to_timedelta(
+            session_kwargs.get("read_timeout_seconds")
+        )
+        if read_timeout is not None:
+            timeout = httpx.Timeout(30.0, read=read_timeout.total_seconds())

Apply the same fix to sse.py lines 72–76.

♻️ Duplicate comments (2)
src/fastmcp/utilities/timeout.py (1)

8-45: Preserve “0 disables timeout” semantics.

Both helpers currently convert 0 (and timedelta(0)) into a zero timeout, which can trigger immediate timeouts where 0 is documented as “disabled”. Consider mapping zero to None (or add an explicit flag for this behavior) to preserve existing semantics.

🔧 Suggested adjustment
 def normalize_timeout_to_timedelta(
     value: int | float | datetime.timedelta | None,
 ) -> datetime.timedelta | None:
@@
-    if value is None:
-        return None
-    if isinstance(value, datetime.timedelta):
-        return value
-    if isinstance(value, int | float):
-        return datetime.timedelta(seconds=float(value))
+    if value is None:
+        return None
+    if isinstance(value, datetime.timedelta):
+        return None if value.total_seconds() == 0 else value
+    if isinstance(value, int | float):
+        return None if float(value) == 0.0 else datetime.timedelta(seconds=float(value))
     raise TypeError(f"Invalid timeout type: {type(value)}")
@@
 def normalize_timeout_to_seconds(
     value: int | float | datetime.timedelta | None,
 ) -> float | None:
@@
-    if value is None:
-        return None
-    if isinstance(value, datetime.timedelta):
-        return value.total_seconds()
-    if isinstance(value, int | float):
-        return float(value)
+    if value is None:
+        return None
+    if isinstance(value, datetime.timedelta):
+        return None if value.total_seconds() == 0 else value.total_seconds()
+    if isinstance(value, int | float):
+        return None if float(value) == 0.0 else float(value)
     raise TypeError(f"Invalid timeout type: {type(value)}")
src/fastmcp/client/prompts.py (1)

221-289: Propagate trace context for task-mode prompt requests.

_get_prompt_as_task bypasses inject_trace_context, so trace metadata is dropped in task-mode. This breaks trace correlation for background prompt execution.

🩹 Proposed fix
-        request = mcp.types.GetPromptRequest(
+        propagated_meta = inject_trace_context(meta)
+        request = mcp.types.GetPromptRequest(
             params=mcp.types.GetPromptRequestParams(
                 name=name,
                 arguments=serialized_arguments,
                 task=mcp.types.TaskMetadata(ttl=ttl),
-                _meta=meta,  # type: ignore[unknown-argument]  # pydantic alias
+                _meta=propagated_meta,  # type: ignore[unknown-argument]  # pydantic alias
             )
         )
🧹 Nitpick comments (1)
src/fastmcp/client/tools_client.py (1)

357-383: Prefer narrower exception handling and preserve tracebacks while parsing.

Catching Exception and logging via error hides stack traces for schema parsing failures. Consider narrowing to validation/parsing errors and using logger.exception for diagnosability.

🔧 Possible adjustment
-        except Exception as e:
-            logger.error(
-                f"[{client_name or 'client'}] Error parsing structured content: {e}"
-            )
+        except (ValueError, TypeError) as e:
+            logger.exception(
+                f"[{client_name or 'client'}] Error parsing structured content: {e}"
+            )

Comment thread src/fastmcp/client/prompts.py
Comment on lines +156 to +158
if isinstance(uri, str):
uri = AnyUrl(uri) # Ensure AnyUrl

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n src/fastmcp/client/resources.py

Repository: jlowin/fastmcp

Length of output: 14558


🏁 Script executed:

python3 << 'EOF'
from pydantic import AnyUrl
try:
    AnyUrl("invalid uri with spaces")
except Exception as e:
    print(f"Exception type: {type(e).__name__}")
    print(f"Exception module: {type(e).__module__}")
    print(f"Exception message: {str(e)}")
EOF

Repository: jlowin/fastmcp

Length of output: 144


Handle invalid URI strings consistently across read_resource, read_resource_mcp, and _read_resource_as_task.

read_resource (lines 250-256) wraps the AnyUrl(uri) conversion in try/except to raise ValueError on invalid URIs, but read_resource_mcp (lines 156-157) and _read_resource_as_task (lines 281-282) allow raw AnyUrl validation errors to propagate. For consistent error reporting across all three methods, apply the same error handling pattern to both locations.

🔧 Suggested handling
-            if isinstance(uri, str):
-                uri = AnyUrl(uri)  # Ensure AnyUrl
+            if isinstance(uri, str):
+                try:
+                    uri = AnyUrl(uri)
+                except Exception as e:
+                    raise ValueError(
+                        f"Provided resource URI is invalid: {str(uri)!r}"
+                    ) from e
@@
-        if isinstance(uri, str):
-            uri = AnyUrl(uri)
+        if isinstance(uri, str):
+            try:
+                uri = AnyUrl(uri)
+            except Exception as e:
+                raise ValueError(
+                    f"Provided resource URI is invalid: {str(uri)!r}"
+                ) from e

Comment thread src/fastmcp/client/task_management.py
Comment on lines +32 to +36
class ClientToolsMixin:
"""Mixin providing tool-related methods for Client."""

# --- Tools ---

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

fd "tools_client.py" --type f

Repository: jlowin/fastmcp

Length of output: 92


🏁 Script executed:

wc -l src/fastmcp/client/tools_client.py

Repository: jlowin/fastmcp

Length of output: 96


🏁 Script executed:

head -50 src/fastmcp/client/tools_client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 2051


🏁 Script executed:

sed -n '32,100p' src/fastmcp/client/tools_client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 3053


🏁 Script executed:

fd "client.py" src/fastmcp/client --type f

Repository: jlowin/fastmcp

Length of output: 121


🏁 Script executed:

head -100 src/fastmcp/client/client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 3395


🏁 Script executed:

grep -n "^class Client" src/fastmcp/client/client.py

Repository: jlowin/fastmcp

Length of output: 104


🏁 Script executed:

sed -n '120,200p' src/fastmcp/client/client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 3861


🏁 Script executed:

sed -n '200,300p' src/fastmcp/client/client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 4058


🏁 Script executed:

sed -n '300,400p' src/fastmcp/client/client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 4895


🏁 Script executed:

grep -n "_await_with_session_monitoring\|_submitted_task_ids" src/fastmcp/client/client.py | head -20

Repository: jlowin/fastmcp

Length of output: 408


🏁 Script executed:

sed -n '647,700p' src/fastmcp/client/client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 2497


🏁 Script executed:

sed -n '1,31p' src/fastmcp/client/tools_client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 1255


🏁 Script executed:

grep -n "_parse_call_tool_result" src/fastmcp/client/tools_client.py

Repository: jlowin/fastmcp

Length of output: 329


🏁 Script executed:

sed -n '332,380p' src/fastmcp/client/tools_client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 2439


🏁 Script executed:

sed -n '332,391p' src/fastmcp/client/tools_client.py | cat -n

Repository: jlowin/fastmcp

Length of output: 2838


Add type annotations to ClientToolsMixin to resolve type-checker errors.

The mixin uses attributes (session, transport, name, _await_with_session_monitoring, _submitted_task_ids) that exist on the concrete Client class but are not declared in the mixin itself. Type checkers cannot resolve these. Add a TYPE_CHECKING block with a Protocol to satisfy static analysis without runtime overhead, aligning with the coding guideline requirement for full type annotations.

Additionally, narrow the exception handling at line 49 from bare Exception to a specific exception type per coding guidelines.

Split the monolithic Client class into domain-specific mixins:
- ClientToolsMixin: tool listing and calling
- ClientPromptsMixin: prompt listing and retrieval
- ClientResourcesMixin: resource and template operations
- ClientTaskManagementMixin: background task management

Also extracts timeout normalization to utilities/timeout.py and fixes
missing trace context propagation in _*_as_task methods.
@jlowin jlowin merged commit 352ff82 into main Jan 19, 2026
11 checks passed
@jlowin jlowin deleted the deps branch January 19, 2026 14:54
gfortaine pushed a commit to gfortaine/fastmcp that referenced this pull request Jan 30, 2026
gfortaine pushed a commit to gfortaine/fastmcp that referenced this pull request Feb 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

client Related to the FastMCP client SDK or client-side functionality. enhancement Improvement to existing functionality. For issues and smaller PR improvements.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant