Skip to content

fix: elicitation scalar return, resource auto-serialization, Client.new() state, prompt errors#3859

Merged
jlowin merged 8 commits intomainfrom
fix/client-audit-bugs
Apr 13, 2026
Merged

fix: elicitation scalar return, resource auto-serialization, Client.new() state, prompt errors#3859
jlowin merged 8 commits intomainfrom
fix/client-audit-bugs

Conversation

@strawgate
Copy link
Copy Markdown
Collaborator

Four client bugs found during a systematic audit of the FastMCP client API surface.

Elicitation scalar return: Handlers returning T directly for ctx.elicit("msg", str) crashed with "must be serializable as a JSON object." The type signature promises this works — now it does by auto-wrapping scalars into {"value": scalar} for ScalarElicitationType schemas.

async def handler(msg, rtype, params, ctx):
    return "Alice"  # Before: crash. After: works.

Resource auto-serialization: Resources returning dict, int, float, bool, or None crashed with TypeError despite the docstring promising auto-conversion. Now auto-serialized to JSON text.

@mcp.resource("data://info")
def info(): return {"key": "value"}  # Before: TypeError. After: JSON text.

Client.new() shared state: copy.copy() shared _task_registry and _submitted_task_ids between original and cloned clients, violating the "fresh session state" contract.

Prompt error message: function_prompt.py dropped the original exception message, unlike tool errors which preserve it.

Fixes #3856

🤖 Generated with Claude Code

@marvin-context-protocol marvin-context-protocol bot added bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. client Related to the FastMCP client SDK or client-side functionality. server Related to FastMCP server implementation or server-side functionality. labels Apr 11, 2026
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@strawgate
Copy link
Copy Markdown
Collaborator Author

Client.new() shared state: copy.copy() shared _task_registry and _submitted_task_ids between original and cloned clients, violating the "fresh session state" contract.

@chrisguidry was this intentional?

@strawgate strawgate force-pushed the fix/client-audit-bugs branch from 678f3d2 to 732a6a6 Compare April 11, 2026 23:44
chatgpt-codex-connector[bot]

This comment was marked as resolved.

@marvin-context-protocol
Copy link
Copy Markdown
Contributor

marvin-context-protocol bot commented Apr 11, 2026

Test Failure Analysis

(Updated to reflect the latest workflow run — 24323832944. Previous analysis about a Windows test timeout is no longer relevant.)

Summary: The Run static analysis workflow failed with two prek hook violations: an unused import caught by ruff-check, and a type-checker error caught by ty.

Root Cause:

  1. Unused import (ruff-check): The Client.new() refactor in this PR removed the if not isinstance(self.transport, StdioTransport): guard, but left StdioTransport in the import list of src/fastmcp/client/client.py. Ruff auto-fixed it (removed the import), causing the hook to fail.

  2. Type error (ty check): The new test test_client_new_resets_mutable_task_state seeds _task_registry with a lambda to verify isolation:

    client._task_registry["task-1"] = lambda: None  # type: ignore[assignment]

    But ty reports this as error[invalid-assignment], not error[assignment]. The # type: ignore[assignment] suppression doesn't match ty's error code. Looking at other patterns in the PR diff (e.g. # type: ignore[call-arg] # ty:ignore[unknown-argument]), the project uses a dual-comment style.

Suggested Solution:

  1. src/fastmcp/client/client.py — Remove StdioTransport from the import block (line ~73):

    # Remove this line:
    StdioTransport,
  2. tests/client/client/test_client.py — Add a ty:ignore suppression (line 777):

    # Before:
    client._task_registry["task-1"] = lambda: None  # type: ignore[assignment]
    # After:
    client._task_registry["task-1"] = lambda: None  # type: ignore[assignment]  # ty:ignore[invalid-assignment]
Detailed Analysis

Failure 1 — ruff-check

ruff check...................................................Failed
- hook id: ruff-check
  Found 1 error (1 fixed, 0 remaining).

Ruff's auto-fix diff:

--- a/src/fastmcp/client/client.py
+++ b/src/fastmcp/client/client.py
@@ -70,7 +70,6 @@ from .transports import (
     PythonStdioTransport,
     SessionKwargs,
     SSETransport,
-    StdioTransport,
     StreamableHttpTransport,
     infer_transport,
 )

The import was needed before this PR to satisfy the isinstance check in Client.new():

if not isinstance(self.transport, StdioTransport):
    new_client._session_state = ClientSessionState()

That condition was removed by the PR (always reset now), but the import was not cleaned up.

Failure 2 — ty check

ty check.....................................................Failed
- hook id: ty
  error[invalid-assignment]: Invalid subscript assignment with key of type `Literal["task-1"]` and value of type `() -> None` on object of type `dict[str, ReferenceType[ToolTask | PromptTask | ResourceTask]]`
  777 |     client._task_registry["task-1"] = lambda: None  # type: ignore[assignment]

_task_registry is typed as dict[str, weakref.ref[ToolTask | PromptTask | ResourceTask]]. Assigning a lambda is intentional in the test (just seeding state), but ty uses invalid-assignment as the error code while only assignment is suppressed. Other lines in this PR already use the dual-comment pattern (e.g. line 59: # type: ignore[call-arg] # ty:ignore[unknown-argument]).

Related Files
  • src/fastmcp/client/client.py:73 — Unused StdioTransport import
  • src/fastmcp/client/client.py:433-457Client.new() method that was refactored (the StdioTransport isinstance check was removed here)
  • tests/client/client/test_client.py:777 — Test with # type: ignore[assignment] that needs a # ty:ignore[invalid-assignment] annotation

@chrisguidry
Copy link
Copy Markdown
Collaborator

Client.new() shared state: copy.copy() shared _task_registry and _submitted_task_ids between original and cloned clients, violating the "fresh session state" contract.

@chrisguidry was this intentional?

No, that does not sound intentional to me, I'd think we'd want each client to start fresh. If the server supports task listing then a client can ask for them, so we don't need to copy these between instances.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

jlowin

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

strawgate and others added 5 commits April 13, 2026 01:30
…ew() state, prompt errors

- Auto-wrap scalar elicitation responses for ScalarElicitationType schemas
  so handlers can return T directly for ctx.elicit("msg", str/int/float)
- Auto-serialize dict/int/float/bool/None resource returns to JSON text
  instead of crashing with TypeError
- Reset _task_registry and _submitted_task_ids in Client.new() so cloned
  clients have independent task tracking state
- Include original error message in prompt render errors (matching tool
  error behavior)

Fixes #3856

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…urces

The comment said "list/tuple of primitives" but the isinstance check
didn't include list or tuple. Now it does, and the comment matches.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ng for JSON resources

Two review-identified bugs:

1. Client.new() shallow-copies _session_kwargs, so the cloned client's
   TaskNotificationHandler still dispatches to the original client.
   Fix: create a fresh _session_kwargs dict with a new handler bound
   to the new client.

2. convert_result() for dict/int/float/bool/None fell through to
   ResourceResult(raw_value) which lost component meta (CSP, permissions).
   The str/bytes path correctly wrapped in ResourceContent with meta.
   Fix: explicitly serialize JSON-native types and wrap with meta,
   matching the str/bytes path. Other types still fall through for
   error handling.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Only replace the message handler with a new TaskNotificationHandler
if the current handler IS a TaskNotificationHandler. If the user
provided a custom message_handler, preserve it in the clone.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jlowin

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@strawgate strawgate force-pushed the fix/client-audit-bugs branch from bbd1ebd to 1759276 Compare April 13, 2026 03:09
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: 1759276a4b

ℹ️ 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".

@jlowin
Copy link
Copy Markdown
Member

jlowin commented Apr 13, 2026

One more thing before merge — the Codex P2 about list[ResourceContent] is still open and I think it's real. The new JSON-native branch in convert_result catches pure list[ResourceContent] returns before the fallthrough to ResourceResult(raw_value), so json.dumps blows up on the ResourceContent items.

Minimal fix is to short-circuit list-of-ResourceContent ahead of the JSON path:

# list[ResourceContent] passes through — otherwise JSON-serialize below
if isinstance(raw_value, list) and all(
    isinstance(item, ResourceContent) for item in raw_value
):
    return ResourceResult(raw_value)

Happy to push it myself if easier — but flagging so it doesn't slip through.

A bare list[ResourceContent] would match the isinstance(list) check
and get JSON-serialized instead of passing through to ResourceResult
normalization. Check for ResourceContent items first.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@jlowin jlowin left a comment

Choose a reason for hiding this comment

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

Thanks!

@jlowin jlowin merged commit db6d7a8 into main Apr 13, 2026
9 checks passed
@jlowin jlowin deleted the fix/client-audit-bugs branch April 13, 2026 17:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. client Related to the FastMCP client SDK or client-side functionality. server Related to FastMCP server implementation or server-side functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Client bugs: elicitation scalar return, resource dict return, raise_on_error+task, Client.new() shared state, prompt error message

3 participants